Shveiauto commited on
Commit
0651359
·
verified ·
1 Parent(s): d351b86

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1270 -0
app.py ADDED
@@ -0,0 +1,1270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash
3
+ import json
4
+ import os
5
+ import logging
6
+ import threading
7
+ import time
8
+ from datetime import datetime
9
+ from huggingface_hub import HfApi, hf_hub_download
10
+ from huggingface_hub.utils import RepositoryNotFoundError
11
+ from werkzeug.utils import secure_filename
12
+ from dotenv import load_dotenv
13
+
14
+ # Загружаем переменные окружения из файла .env (если он есть)
15
+ load_dotenv()
16
+
17
+ app = Flask(__name__)
18
+ # Обязательно смените секретный ключ на свой собственный сложный ключ!
19
+ app.secret_key = os.getenv('FLASK_SECRET_KEY', 'fallback_very_secret_soola_key_123')
20
+ DATA_FILE = 'data_soola.json'
21
+ USERS_FILE = 'users_soola.json'
22
+
23
+ # Список файлов для синхронизации
24
+ SYNC_FILES = [DATA_FILE, USERS_FILE]
25
+
26
+ # Настройки Hugging Face
27
+ REPO_ID = os.getenv('HF_REPO_ID', "Kgshop/Soola") # Получаем из .env или используем дефолтный
28
+ HF_TOKEN_WRITE = os.getenv("HF_TOKEN") # Токен с правом записи
29
+ HF_TOKEN_READ = os.getenv("HF_TOKEN_READ", HF_TOKEN_WRITE) # Токен чтения (может быть тот же)
30
+
31
+ # Адрес магазина
32
+ STORE_ADDRESS = "Рынок Дордой, Джунхай, терминал, 38"
33
+
34
+ # Валюта (только KGS)
35
+ CURRENCY_CODE = 'KGS'
36
+ CURRENCY_NAME = 'Кыргызский сом (с)'
37
+
38
+ # Номер WhatsApp для заказов
39
+ WHATSAPP_NUMBER = "996997703090" # <-- ИЗМЕНЕННЫЙ НОМЕР
40
+
41
+ # Настройка логирования
42
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
43
+
44
+ # --- Функции работы с данными и пользователями ---
45
+ # (Эти функции остаются без изменений, как в предыдущем ответе)
46
+ def load_data():
47
+ """Загрузка данных о товарах и категориях."""
48
+ try:
49
+ download_db_from_hf()
50
+ with open(DATA_FILE, 'r', encoding='utf-8') as file:
51
+ data = json.load(file)
52
+ logging.info(f"Данные успешно загружены из {DATA_FILE}")
53
+ if not isinstance(data, dict):
54
+ logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.")
55
+ return {'products': [], 'categories': []}
56
+ if 'products' not in data: data['products'] = []
57
+ if 'categories' not in data: data['categories'] = []
58
+ return data
59
+ except FileNotFoundError:
60
+ logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачать с HF.")
61
+ try:
62
+ if not os.path.exists(DATA_FILE):
63
+ with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
64
+ logging.info(f"Создан пустой файл {DATA_FILE}")
65
+ return {'products': [], 'categories': []}
66
+ else:
67
+ with open(DATA_FILE, 'r', encoding='utf-8') as file:
68
+ data = json.load(file)
69
+ logging.info(f"Данные успешно загружены из {DATA_FILE} после попытки скачивания.")
70
+ if not isinstance(data, dict): return {'products': [], 'categories': []}
71
+ if 'products' not in data: data['products'] = []
72
+ if 'categories' not in data: data['categories'] = []
73
+ return data
74
+ except (FileNotFoundError, RepositoryNotFoundError) as e:
75
+ logging.warning(f"Файл {DATA_FILE} не найден локально и ошибка при доступе к репозиторию HF ({e}). Создание пустой структуры.")
76
+ if not os.path.exists(DATA_FILE):
77
+ with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
78
+ return {'products': [], 'categories': []}
79
+ except json.JSONDecodeError:
80
+ logging.error(f"Ошибка декодирования JSON в {DATA_FILE} после попытки скачивания.")
81
+ return {'products': [], 'categories': []}
82
+ except Exception as e:
83
+ logging.error(f"Неизвестная ошибка при загрузке данных после попытки скачивания: {e}")
84
+ return {'products': [], 'categories': []}
85
+ except json.JSONDecodeError:
86
+ logging.error(f"Ошибка декодирования JSON в локальном {DATA_FILE}. Файл может быть поврежден. Возврат пустой структуры.")
87
+ return {'products': [], 'categories': []}
88
+ except Exception as e:
89
+ logging.error(f"Неизвестная ��шибка при загрузке данных ({DATA_FILE}): {e}", exc_info=True)
90
+ return {'products': [], 'categories': []}
91
+
92
+ def save_data(data):
93
+ """Сохранение данных о товарах и категориях."""
94
+ try:
95
+ with open(DATA_FILE, 'w', encoding='utf-8') as file:
96
+ json.dump(data, file, ensure_ascii=False, indent=4)
97
+ logging.info(f"Данные успешно сохранены в {DATA_FILE}")
98
+ upload_db_to_hf(specific_file=DATA_FILE)
99
+ except Exception as e:
100
+ logging.error(f"Ошибка при сохранении данных в {DATA_FILE}: {e}", exc_info=True)
101
+
102
+ def load_users():
103
+ """Загрузка данных пользователей."""
104
+ try:
105
+ with open(USERS_FILE, 'r', encoding='utf-8') as file:
106
+ users = json.load(file)
107
+ logging.info(f"Данные пользователей успешно загружены из {USERS_FILE}")
108
+ return users if isinstance(users, dict) else {}
109
+ except FileNotFoundError:
110
+ logging.warning(f"Локальный файл {USERS_FILE} не найден. Попытка скачать с HF.")
111
+ try:
112
+ download_db_from_hf(specific_file=USERS_FILE)
113
+ with open(USERS_FILE, 'r', encoding='utf-8') as file:
114
+ users = json.load(file)
115
+ logging.info(f"Данные пользователей загружены из {USERS_FILE} после скачивания.")
116
+ return users if isinstance(users, dict) else {}
117
+ except (FileNotFoundError, RepositoryNotFoundError):
118
+ logging.warning(f"Файл {USERS_FILE} не найден локально и в репозитории HF. Создание пустого файла.")
119
+ with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f)
120
+ return {}
121
+ except json.JSONDecodeError:
122
+ logging.error(f"Ошибка декодирования JSON в {USERS_FILE} после скачивания.")
123
+ return {}
124
+ except Exception as e:
125
+ logging.error(f"Неизвестная ошибка при загрузке пользователей после скачивания: {e}", exc_info=True)
126
+ return {}
127
+ except json.JSONDecodeError:
128
+ logging.error(f"Ошибка декодирования JSON в локальном {USERS_FILE}. Файл может быть поврежден. Возврат пустого словаря.")
129
+ return {}
130
+ except Exception as e:
131
+ logging.error(f"Неизвестная ошибка при загрузке пользователей ({USERS_FILE}): {e}", exc_info=True)
132
+ return {}
133
+
134
+ def save_users(users):
135
+ """Сохранение данных пользователей."""
136
+ try:
137
+ with open(USERS_FILE, 'w', encoding='utf-8') as file:
138
+ json.dump(users, file, ensure_ascii=False, indent=4)
139
+ logging.info(f"Данные пользователей успешно сохранены в {USERS_FILE}")
140
+ upload_db_to_hf(specific_file=USERS_FILE)
141
+ except Exception as e:
142
+ logging.error(f"Ошибка при сохранении данных пользователей в {USERS_FILE}: {e}", exc_info=True)
143
+
144
+ # --- Функции синхронизации с Hugging Face ---
145
+ # (Эти функции остаются без изменений, как в предыдущем ответе)
146
+ def upload_db_to_hf(specific_file=None):
147
+ """Загрузка файлов данных на Hugging Face."""
148
+ if not HF_TOKEN_WRITE:
149
+ logging.warning("HF_TOKEN (для записи) не установлен. Загрузка на HF пропущена.")
150
+ return
151
+ if not REPO_ID:
152
+ logging.error("HF_REPO_ID не установлен. Загрузка на HF невозможна.")
153
+ return
154
+ try:
155
+ api = HfApi()
156
+ files_to_upload = [specific_file] if specific_file else SYNC_FILES
157
+ logging.info(f"Начало загрузки файлов {files_to_upload} на HF репозиторий {REPO_ID}...")
158
+ for file_name in files_to_upload:
159
+ if os.path.exists(file_name):
160
+ try:
161
+ api.upload_file(path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID,
162
+ repo_type="dataset", token=HF_TOKEN_WRITE,
163
+ commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
164
+ logging.info(f"Файл {file_name} успешно загружен на HF.")
165
+ except Exception as e:
166
+ logging.error(f"Ошибка при загрузке файла {file_name} на HF: {e}")
167
+ else:
168
+ logging.warning(f"Файл {file_name} не найден локально, пропуск загрузки.")
169
+ logging.info("Загрузка файлов на HF з��вершена.")
170
+ except Exception as e:
171
+ logging.error(f"Общая ошибка при инициализации или загрузке на HF: {e}", exc_info=True)
172
+
173
+ def download_db_from_hf(specific_file=None):
174
+ """Скачивание файлов данных с Hugging Face."""
175
+ if not REPO_ID:
176
+ logging.error("HF_REPO_ID не установлен. Скачивание с HF невозможно.")
177
+ return
178
+ files_to_download = [specific_file] if specific_file else SYNC_FILES
179
+ logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID}...")
180
+ downloaded_files_count = 0
181
+ try:
182
+ for file_name in files_to_download:
183
+ try:
184
+ local_path = hf_hub_download(repo_id=REPO_ID, filename=file_name, repo_type="dataset",
185
+ token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False,
186
+ force_download=True)
187
+ logging.info(f"Файл {file_name} успешно скачан из HF в {local_path}.")
188
+ downloaded_files_count += 1
189
+ except RepositoryNotFoundError:
190
+ logging.error(f"Репозиторий {REPO_ID} не найден на HF. Скачивание прервано.")
191
+ break
192
+ except Exception as e:
193
+ if "404" in str(e) or isinstance(e, FileNotFoundError):
194
+ logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск.")
195
+ else:
196
+ logging.error(f"Ошибка при скачивании файла {file_name} с HF: {e}", exc_info=True)
197
+ logging.info(f"Скачивание файлов с HF завершено. Скачано: {downloaded_files_count}/{len(files_to_download)}.")
198
+ except RepositoryNotFoundError:
199
+ logging.error(f"Репозиторий {REPO_ID} не найден на HF. Скачивание не выполнено.")
200
+ except Exception as e:
201
+ logging.error(f"Общая ошибка при попытке скачивания с HF: {e}", exc_info=True)
202
+
203
+ def periodic_backup():
204
+ """Периодическая загрузка данных на HF."""
205
+ backup_interval = 1800 # 30 минут
206
+ logging.info(f"Настройка периодического резервного копирования каждые {backup_interval} секунд.")
207
+ while True:
208
+ time.sleep(backup_interval)
209
+ logging.info("Запуск периодического резервного копирования...")
210
+ upload_db_to_hf()
211
+ logging.info("Периодическое резервное копирование завершено.")
212
+
213
+ # --- Маршруты Flask ---
214
+
215
+ @app.route('/')
216
+ def catalog():
217
+ """Главная страница каталога товаров."""
218
+ data = load_data()
219
+ products = data.get('products', [])
220
+ categories = data.get('categories', [])
221
+ is_authenticated = 'user' in session
222
+
223
+ # Обновленный HTML с темно-зеленой темой
224
+ catalog_html = '''
225
+ <!DOCTYPE html>
226
+ <html lang="ru">
227
+ <head>
228
+ <meta charset="UTF-8">
229
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
230
+ <title>Soola Cosmetics - Каталог</title>
231
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
232
+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
233
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
234
+ <style>
235
+ :root {
236
+ --primary-green: #1C4A3A; /* Основной темно-зеленый */
237
+ --primary-green-darker: #113126; /* Темнее для hover */
238
+ --accent-green: #38a169; /* Зеленый для корзины/заказа */
239
+ --accent-green-darker: #2f855a; /* Темнее для hover */
240
+ --delete-red: #ef4444; /* Красный для удаления */
241
+ --delete-red-darker: #dc2626; /* Темнее для hover */
242
+ --link-blue: #3b82f6;
243
+ --light-bg: #f9f9f9;
244
+ --light-text: #333;
245
+ --light-card-bg: #fff;
246
+ --light-border: #eee;
247
+ --dark-bg: #1a202c;
248
+ --dark-text: #e2e8f0;
249
+ --dark-card-bg: #2d3748;
250
+ --dark-border: #4a5568;
251
+ --focus-ring-color: rgba(28, 74, 58, 0.3); /* Зеленое свечение фокуса */
252
+ }
253
+ * { margin: 0; padding: 0; box-sizing: border-box; }
254
+ body { font-family: 'Poppins', sans-serif; background: var(--light-bg); color: var(--light-text); line-height: 1.6; transition: background 0.3s, color 0.3s; }
255
+ body.dark-mode { background: var(--dark-bg); color: var(--dark-text); }
256
+ .container { max-width: 1300px; margin: 0 auto; padding: 20px; }
257
+ .header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid var(--light-border); }
258
+ body.dark-mode .header { border-bottom-color: var(--dark-border); }
259
+ .header h1 { font-size: 1.8rem; font-weight: 600; color: var(--primary-green); } /* Логотип - зеленый */
260
+ .auth-links { display: flex; gap: 15px; align-items: center; }
261
+ .auth-links a { color: var(--link-blue); text-decoration: none; font-weight: 500; }
262
+ .auth-links a:hover { text-decoration: underline; }
263
+ body.dark-mode .auth-links a { color: #63b3ed; }
264
+ .auth-links span { font-weight: 500; }
265
+ body.dark-mode .auth-links span { color: #cbd5e0;}
266
+ .theme-toggle { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #718096; transition: color 0.3s ease; }
267
+ .theme-toggle:hover { color: var(--link-blue); }
268
+ body.dark-mode .theme-toggle { color: #a0aec0; }
269
+ body.dark-mode .theme-toggle:hover { color: #63b3ed; }
270
+ .store-address { padding: 15px; text-align: center; background-color: var(--light-card-bg); margin: 20px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); font-size: 1rem; color: #555; }
271
+ body.dark-mode .store-address { background-color: var(--dark-card-bg); color: #cbd5e0; }
272
+ .filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
273
+ .search-container { margin: 20px 0; text-align: center; }
274
+ #search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid #e2e8f0; border-radius: 25px; outline: none; box-shadow: 0 2px 5px rgba(0,0,0,0.05); transition: all 0.3s ease; }
275
+ body.dark-mode #search-input { background-color: var(--dark-card-bg); border-color: var(--dark-border); color: var(--dark-text); }
276
+ #search-input:focus { border-color: var(--primary-green); box-shadow: 0 0 0 3px var(--focus-ring-color); }
277
+ body.dark-mode #search-input:focus { border-color: #48BB78; box-shadow: 0 0 0 3px rgba(72, 187, 120, 0.3); } /* Светлее для темной темы */
278
+ .category-filter { padding: 8px 16px; border: 1px solid #e2e8f0; border-radius: 20px; background-color: var(--light-card-bg); cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); font-size: 0.9rem; font-weight: 400; }
279
+ body.dark-mode .category-filter { background-color: var(--dark-card-bg); border-color: var(--dark-border); color: #cbd5e0; }
280
+ .category-filter.active, .category-filter:hover { background-color: var(--primary-green); color: white; border-color: var(--primary-green); box-shadow: 0 2px 10px rgba(28, 74, 58, 0.3); }
281
+ body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover { background-color: #48BB78; border-color: #48BB78; color: #1a202c; box-shadow: 0 2px 10px rgba(72, 187, 120, 0.4); } /* Светлее для темной */
282
+ .products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; padding: 10px; }
283
+ @media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
284
+ .product { background: var(--light-card-bg); border-radius: 15px; padding: 0; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; height: 100%; }
285
+ body.dark-mode .product { background: var(--dark-card-bg); box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); }
286
+ .product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
287
+ body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
288
+ .product-image { width: 100%; aspect-ratio: 1 / 1; background-color: var(--light-card-bg); /* Может быть белым всегда? */ border-radius: 10px 10px 0 0; overflow: hidden; display: flex; justify-content: center; align-items: center; margin-bottom: 0; }
289
+ body.dark-mode .product-image { background-color: var(--dark-bg); } /* Темнее фон картинки в темной теме */
290
+ .product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; }
291
+ .product-image img:hover { transform: scale(1.08); }
292
+ .product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
293
+ .product h2 { font-size: 1.1rem; font-weight: 600; margin: 0 0 8px 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--light-text); }
294
+ body.dark-mode .product h2 { color: var(--dark-text); }
295
+ .product-price { font-size: 1.2rem; color: var(--primary-green); font-weight: 700; text-align: center; margin: 5px 0; }
296
+ body.dark-mode .product-price { color: #48BB78; } /* Светлее зеленый в темной теме */
297
+ .product-description { font-size: 0.85rem; color: #718096; text-align: center; margin-bottom: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
298
+ body.dark-mode .product-description { color: #a0aec0; }
299
+ .product-actions { padding: 0 15px 15px 15px; display: flex; flex-direction: column; gap: 8px; }
300
+ .product-button { display: block; width: 100%; padding: 10px; border: none; border-radius: 8px; background-color: var(--primary-green); color: white; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); text-align: center; text-decoration: none; }
301
+ .product-button:hover { background-color: var(--primary-green-darker); box-shadow: 0 4px 15px rgba(17, 49, 38, 0.4); transform: translateY(-2px); }
302
+ .product-button i { margin-right: 5px; }
303
+ .add-to-cart { background-color: var(--accent-green); }
304
+ .add-to-cart:hover { background-color: var(--accent-green-darker); box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
305
+ #cart-button { position: fixed; bottom: 25px; right: 25px; background-color: var(--primary-green); 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, 74, 58, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; }
306
+ #cart-button .fa-shopping-cart { margin-right: 0; }
307
+ #cart-button span { position: absolute; top: -5px; right: -5px; background-color: var(--accent-green); color: white; border-radius: 50%; padding: 2px 6px; font-size: 0.7rem; font-weight: bold; }
308
+ .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(5px); overflow-y: auto; }
309
+ .modal-content { background: var(--light-card-bg); margin: 5% auto; padding: 25px; border-radius: 15px; width: 90%; max-width: 700px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); animation: slideIn 0.3s ease-out; position: relative; }
310
+ body.dark-mode .modal-content { background: var(--dark-card-bg); color: var(--dark-text); }
311
+ @keyframes slideIn { from { transform: translateY(-30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
312
+ .close { position: absolute; top: 15px; right: 15px; font-size: 1.8rem; color: #aaa; cursor: pointer; transition: color 0.3s; line-height: 1; }
313
+ .close:hover { color: var(--light-text); }
314
+ body.dark-mode .close { color: #718096; }
315
+ body.dark-mode .close:hover { color: #cbd5e0; }
316
+ .modal-content h2 { margin-top: 0; margin-bottom: 20px; color: var(--primary-green); display: flex; align-items: center; gap: 10px;}
317
+ body.dark-mode .modal-content h2 { color: #48BB78; } /* Светлее зеленый для заголовка */
318
+ .cart-item { display: grid; grid-template-columns: auto 1fr auto auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid var(--light-border); }
319
+ body.dark-mode .cart-item { border-bottom-color: var(--dark-border); }
320
+ .cart-item:last-child { border-bottom: none; }
321
+ .cart-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: var(--light-card-bg); padding: 5px; grid-column: 1; }
322
+ body.dark-mode .cart-item img { background-color: var(--dark-bg); }
323
+ .cart-item-details { grid-column: 2; }
324
+ .cart-item-details strong { display: block; margin-bottom: 5px; }
325
+ .cart-item-price { font-size: 0.9rem; color: #555; }
326
+ body.dark-mode .cart-item-price { color: #a0aec0; }
327
+ .cart-item-total { font-weight: bold; text-align: right; grid-column: 3; }
328
+ .cart-item-remove { grid-column: 4; background:none; border:none; color: var(--delete-red); cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; }
329
+ .cart-item-remove:hover { color: var(--delete-red-darker); }
330
+ .quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 1rem; margin: 10px 0; box-sizing: border-box; }
331
+ body.dark-mode .quantity-input, body.dark-mode .color-select { background-color: var(--dark-bg); border-color: var(--dark-border); color: var(--dark-text); }
332
+ .cart-summary { margin-top: 20px; text-align: right; border-top: 1px solid var(--light-border); padding-top: 15px; }
333
+ body.dark-mode .cart-summary { border-top-color: var(--dark-border); }
334
+ .cart-summary strong { font-size: 1.2rem; }
335
+ .cart-actions { margin-top: 25px; display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
336
+ .cart-actions .product-button { width: auto; flex-grow: 1; }
337
+ .clear-cart { background-color: #718096; } /* Серый для очистки */
338
+ .clear-cart:hover { background-color: #4a5568; box-shadow: 0 4px 15px rgba(74, 85, 104, 0.4); }
339
+ .order-button { background-color: var(--accent-green); } /* Зеленый для заказа */
340
+ .order-button:hover { background-color: var(--accent-green-darker); box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
341
+ .notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: var(--accent-green); 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; font-size: 0.9rem;}
342
+ .notification.show { opacity: 1;}
343
+ .no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #777; }
344
+ body.dark-mode .no-results-message { color: #a0aec0; }
345
+ </style>
346
+ </head>
347
+ <body>
348
+ <div class="container">
349
+ <div class="header">
350
+ <h1>Soola Cosmetics</h1>
351
+ <div class="auth-links">
352
+ {% if is_authenticated %}
353
+ <span>Привет, {{ session.get('user_info', {}).get('first_name', session['user']) }}!</span>
354
+ <a href="{{ url_for('logout') }}">Выйти</a>
355
+ {% else %}
356
+ <a href="{{ url_for('login') }}">Войти</a>
357
+ {% endif %}
358
+ </div>
359
+ <button class="theme-toggle" onclick="toggleTheme()" aria-label="Переключить тему">
360
+ <i class="fas fa-moon"></i>
361
+ </button>
362
+ </div>
363
+
364
+ <div class="store-address">Наш адрес: {{ store_address }}</div>
365
+
366
+ <div class="filters-container">
367
+ <button class="category-filter active" data-category="all">Все категории</button>
368
+ {% for category in categories %}
369
+ <button class="category-filter" data-category="{{ category }}">{{ category }}</button>
370
+ {% endfor %}
371
+ </div>
372
+
373
+ <div class="search-container">
374
+ <input type="text" id="search-input" placeholder="Поиск по названию или описанию...">
375
+ </div>
376
+
377
+ <div class="products-grid" id="products-grid">
378
+ {% for product in products %}
379
+ <div class="product"
380
+ data-name="{{ product['name']|lower }}"
381
+ data-description="{{ product.get('description', '')|lower }}"
382
+ data-category="{{ product.get('category', 'Без категории') }}">
383
+ <div class="product-image">
384
+ {% if product.get('photos') and product['photos']|length > 0 %}
385
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
386
+ alt="{{ product['name'] }}"
387
+ loading="lazy">
388
+ {% else %}
389
+ <img src="https://via.placeholder.com/250x250.png?text=No+Image" alt="No Image" loading="lazy">
390
+ {% endif %}
391
+ </div>
392
+ <div class="product-info">
393
+ <h2>{{ product['name'] }}</h2>
394
+ {% if is_authenticated %}
395
+ <div class="product-price">{{ "%.2f"|format(product['price']) }} {{ currency_code }}</div>
396
+ {% else %}
397
+ <div class="product-price">Цена доступна после входа</div>
398
+ {% endif %}
399
+ <p class="product-description">{{ product.get('description', '')[:50] }}{% if product.get('description', '')|length > 50 %}...{% endif %}</p>
400
+ </div>
401
+ <div class="product-actions">
402
+ <button class="product-button" onclick="openModal({{ loop.index0 }})">Подробнее</button>
403
+ {% if is_authenticated %}
404
+ <button class="product-button add-to-cart" onclick="openQuantityModal({{ loop.index0 }})">
405
+ <i class="fas fa-cart-plus"></i> В корзину
406
+ </button>
407
+ {% endif %}
408
+ </div>
409
+ </div>
410
+ {% endfor %}
411
+ {% if not products %}
412
+ <p class="no-results-message">Товары пока не добавлены.</p>
413
+ {% endif %}
414
+ </div>
415
+ </div>
416
+
417
+ <!-- Product Modal -->
418
+ <div id="productModal" class="modal">
419
+ <div class="modal-content">
420
+ <span class="close" onclick="closeModal('productModal')" aria-label="Закрыть">×</span>
421
+ <div id="modalContent">Загрузка...</div>
422
+ </div>
423
+ </div>
424
+
425
+ <!-- Quantity and Color Modal -->
426
+ <div id="quantityModal" class="modal">
427
+ <div class="modal-content">
428
+ <span class="close" onclick="closeModal('quantityModal')" aria-label="Закрыть">×</span>
429
+ <h2>Укажите количество и цвет</h2>
430
+ <label for="quantityInput">Количество:</label>
431
+ <input type="number" id="quantityInput" class="quantity-input" min="1" value="1">
432
+ <label for="colorSelect">Цвет/Вариант:</label>
433
+ <select id="colorSelect" class="color-select"></select>
434
+ {# Используем класс add-to-cart для кнопки #}
435
+ <button class="product-button add-to-cart" onclick="confirmAddToCart()"><i class="fas fa-check"></i> Добавить в корзину</button>
436
+ </div>
437
+ </div>
438
+
439
+ <!-- Cart Modal -->
440
+ <div id="cartModal" class="modal">
441
+ <div class="modal-content">
442
+ <span class="close" onclick="closeModal('cartModal')" aria-label="Закрыть">×</span>
443
+ <h2><i class="fas fa-shopping-cart"></i> Ваша корзина</h2>
444
+ <div id="cartContent"><p style="text-align: center; padding: 20px;">Ваша корзина пуста.</p></div>
445
+ <div class="cart-summary">
446
+ <strong>Итого: <span id="cartTotal">0.00</span> {{ currency_code }}</strong>
447
+ </div>
448
+ <div class="cart-actions">
449
+ <button class="product-button clear-cart" onclick="clearCart()">
450
+ <i class="fas fa-trash"></i> Очистить корзину
451
+ </button>
452
+ <button class="product-button order-button" onclick="orderViaWhatsApp()">
453
+ <i class="fab fa-whatsapp"></i> Заказать через WhatsApp
454
+ </button>
455
+ </div>
456
+ </div>
457
+ </div>
458
+
459
+ <!-- Cart Floating Button -->
460
+ <button id="cart-button" onclick="openCartModal()" aria-label="Открыть корзину">
461
+ <i class="fas fa-shopping-cart"></i>
462
+ <span id="cart-count">0</span>
463
+ </button>
464
+
465
+ <!-- Notification Placeholder -->
466
+ <div id="notification-placeholder" style="position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1002; display: flex; flex-direction: column; align-items: center; gap: 10px;"></div>
467
+
468
+
469
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
470
+ <script>
471
+ // --- Global Variables ---
472
+ const products = {{ products|tojson }};
473
+ const repoId = '{{ repo_id }}';
474
+ const currencyCode = '{{ currency_code }}';
475
+ const isAuthenticated = {{ is_authenticated|tojson }};
476
+ const whatsappNumber = '{{ whatsapp_number }}'; // Получаем номер из Flask
477
+ let selectedProductIndex = null;
478
+ let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
479
+
480
+ // --- Theme ---
481
+ function toggleTheme() {
482
+ document.body.classList.toggle('dark-mode');
483
+ const icon = document.querySelector('.theme-toggle i');
484
+ const isDarkMode = document.body.classList.contains('dark-mode');
485
+ icon.classList.toggle('fa-moon', !isDarkMode);
486
+ icon.classList.toggle('fa-sun', isDarkMode);
487
+ localStorage.setItem('soolaTheme', isDarkMode ? 'dark' : 'light');
488
+ }
489
+ function applyInitialTheme() {
490
+ const savedTheme = localStorage.getItem('soolaTheme');
491
+ if (savedTheme === 'dark') {
492
+ document.body.classList.add('dark-mode');
493
+ const icon = document.querySelector('.theme-toggle i');
494
+ if (icon) icon.classList.replace('fa-moon', 'fa-sun');
495
+ }
496
+ }
497
+
498
+ // --- Auto Login ---
499
+ function attemptAutoLogin() {
500
+ const storedUser = localStorage.getItem('soolaUser');
501
+ if (storedUser && !isAuthenticated) {
502
+ fetch('/auto_login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ login: storedUser }) })
503
+ .then(response => { if (response.ok) window.location.reload(); else localStorage.removeItem('soolaUser'); })
504
+ .catch(() => localStorage.removeItem('soolaUser'));
505
+ }
506
+ }
507
+
508
+ // --- Modals ---
509
+ function openModal(index) {
510
+ loadProductDetails(index);
511
+ const modal = document.getElementById('productModal');
512
+ if (modal) { modal.style.display = "block"; document.body.style.overflow = 'hidden'; }
513
+ }
514
+ function closeModal(modalId) {
515
+ const modal = document.getElementById(modalId);
516
+ if (modal) modal.style.display = "none";
517
+ if (!document.querySelector('.modal[style*="display: block"]')) document.body.style.overflow = 'auto';
518
+ }
519
+ function loadProductDetails(index) {
520
+ const modalContent = document.getElementById('modalContent');
521
+ if (!modalContent) return;
522
+ modalContent.innerHTML = '<p style="text-align:center; padding: 40px;">Загрузка...</p>';
523
+ fetch('/product/' + index)
524
+ .then(response => response.ok ? response.text() : Promise.reject(`Ошибка ${response.status}`))
525
+ .then(data => { modalContent.innerHTML = data; initializeSwiper(); })
526
+ .catch(error => { modalContent.innerHTML = `<p style="color: red; text-align:center; padding: 40px;">Не удалось загрузить информацию. ${error}</p>`; });
527
+ }
528
+ function initializeSwiper() {
529
+ const swiperContainer = document.querySelector('#productModal .swiper-container');
530
+ if (swiperContainer) {
531
+ new Swiper(swiperContainer, {
532
+ slidesPerView: 1, spaceBetween: 20, loop: true, grabCursor: true,
533
+ pagination: { el: '.swiper-pagination', clickable: true },
534
+ navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
535
+ zoom: { maxRatio: 3, containerClass: 'swiper-zoom-container' },
536
+ autoplay: { delay: 4000, disableOnInteraction: true },
537
+ });
538
+ }
539
+ }
540
+
541
+ // --- Cart Logic ---
542
+ function openQuantityModal(index) {
543
+ if (!isAuthenticated) { alert('Пожалуйста, войдите в систему.'); window.location.href = '/login'; return; }
544
+ selectedProductIndex = index;
545
+ const product = products[index];
546
+ if (!product) { alert("Ошибка: товар не найден."); return; }
547
+ const colorSelect = document.getElementById('colorSelect');
548
+ const colorLabel = document.querySelector('label[for="colorSelect"]');
549
+ colorSelect.innerHTML = '';
550
+ const validColors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : [];
551
+ if (validColors.length > 0) {
552
+ validColors.forEach(color => { const option = document.createElement('option'); option.value = color.trim(); option.text = color.trim(); colorSelect.appendChild(option); });
553
+ colorSelect.style.display = 'block'; if(colorLabel) colorLabel.style.display = 'block';
554
+ } else {
555
+ colorSelect.style.display = 'none'; if(colorLabel) colorLabel.style.display = 'none';
556
+ }
557
+ document.getElementById('quantityInput').value = 1;
558
+ const modal = document.getElementById('quantityModal');
559
+ if(modal) { modal.style.display = 'block'; document.body.style.overflow = 'hidden'; }
560
+ }
561
+ function confirmAddToCart() {
562
+ if (selectedProductIndex === null) return;
563
+ const quantity = parseInt(document.getElementById('quantityInput').value);
564
+ const colorSelect = document.getElementById('colorSelect');
565
+ const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A';
566
+ if (isNaN(quantity) || quantity <= 0) { alert("Укажите корректное количество (> 0)."); return; }
567
+ const product = products[selectedProductIndex]; if (!product) { alert("Ошибка: товар не найден."); return; }
568
+ const cartItemId = `${product.name}-${color}`;
569
+ const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
570
+ if (existingItemIndex > -1) cart[existingItemIndex].quantity += quantity;
571
+ else cart.push({ id: cartItemId, name: product.name, price: product.price, photo: product.photos?.[0], quantity: quantity, color: color });
572
+ localStorage.setItem('soolaCart', JSON.stringify(cart));
573
+ closeModal('quantityModal');
574
+ updateCartButton();
575
+ showNotification(`${product.name} добавлен в корзину!`);
576
+ }
577
+ function updateCartButton() {
578
+ const cartCountElement = document.getElementById('cart-count');
579
+ const cartButton = document.getElementById('cart-button');
580
+ if (!cartCountElement || !cartButton) return;
581
+ let totalItems = cart.reduce((sum, item) => sum + item.quantity, 0);
582
+ if (totalItems > 0) { cartCountElement.textContent = totalItems; cartButton.style.display = 'flex'; }
583
+ else { cartCountElement.textContent = '0'; cartButton.style.display = 'none'; }
584
+ }
585
+ function openCartModal() {
586
+ const cartContent = document.getElementById('cartContent');
587
+ const cartTotalElement = document.getElementById('cartTotal');
588
+ if (!cartContent || !cartTotalElement) return;
589
+ let total = 0;
590
+ if (cart.length === 0) {
591
+ cartContent.innerHTML = '<p style="text-align: center; padding: 20px;">Ваша корзина пуста.</p>';
592
+ cartTotalElement.textContent = '0.00';
593
+ } else {
594
+ cartContent.innerHTML = cart.map(item => {
595
+ const itemTotal = item.price * item.quantity; total += itemTotal;
596
+ const photoUrl = item.photo ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}` : 'https://via.placeholder.com/60x60.png?text=N/A';
597
+ const colorText = item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
598
+ return `<div class="cart-item">
599
+ <img src="${photoUrl}" alt="${item.name}">
600
+ <div class="cart-item-details"><strong>${item.name}${colorText}</strong><p class="cart-item-price">${item.price.toFixed(2)} ${currencyCode} × ${item.quantity}</p></div>
601
+ <span class="cart-item-total">${itemTotal.toFixed(2)} ${currencyCode}</span>
602
+ <button class="cart-item-remove" onclick="removeFromCart('${item.id}')" title="Удалить товар">×</button>
603
+ </div>`;
604
+ }).join('');
605
+ cartTotalElement.textContent = total.toFixed(2);
606
+ }
607
+ const modal = document.getElementById('cartModal');
608
+ if (modal) { modal.style.display = 'block'; document.body.style.overflow = 'hidden'; }
609
+ }
610
+ function removeFromCart(itemId) {
611
+ cart = cart.filter(item => item.id !== itemId);
612
+ localStorage.setItem('soolaCart', JSON.stringify(cart));
613
+ openCartModal(); updateCartButton();
614
+ }
615
+ function clearCart() {
616
+ if (confirm("Вы уверены, что хотите очистить корзину?")) {
617
+ cart = []; localStorage.removeItem('soolaCart'); openCartModal(); updateCartButton();
618
+ }
619
+ }
620
+ function orderViaWhatsApp() {
621
+ if (cart.length === 0) { alert("Корзина пуста!"); return; }
622
+ let total = 0; let orderText = "Новый Заказ от Soola Cosmetics:\n\n";
623
+ cart.forEach((item, index) => {
624
+ const itemTotal = item.price * item.quantity; total += itemTotal;
625
+ const colorText = item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
626
+ orderText += `${index + 1}. ${item.name}${colorText} - ${item.price.toFixed(2)} ${currencyCode} × ${item.quantity} = ${itemTotal.toFixed(2)} ${currencyCode}\n`;
627
+ });
628
+ orderText += `\n*Итого: ${total.toFixed(2)} ${currencyCode}*\n\n`;
629
+ const userInfo = {{ session.get('user_info', {})|tojson }};
630
+ if (userInfo && userInfo.login) {
631
+ orderText += `Заказчик: ${userInfo.get('first_name', '')} ${userInfo.get('last_name', '')}\nЛогин: ${userInfo.login}\nСтрана: ${userInfo.get('country', 'Не указ.')}\nГород: ${userInfo.get('city', 'Не указ.')}\n`;
632
+ } else { orderText += `Заказчик: (Не авторизован)\n`; }
633
+ const now = new Date(); orderText += `\nДата заказа: ${now.toLocaleString('ru-RU')}`;
634
+ // Используем encodeURIComponent для корректной передачи текста
635
+ const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
636
+ window.open(whatsappUrl, '_blank');
637
+ }
638
+
639
+ // --- Filtering and Search ---
640
+ function filterProducts() {
641
+ const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
642
+ const activeCategory = document.querySelector('.category-filter.active')?.dataset.category || 'all';
643
+ const grid = document.getElementById('products-grid');
644
+ let visibleProducts = 0;
645
+ grid.querySelectorAll('.no-results-message').forEach(el => el.remove()); // Удаляем старые сообщения
646
+ grid.querySelectorAll('.product').forEach(productElement => {
647
+ const name = productElement.dataset.name || '';
648
+ const description = productElement.dataset.description || '';
649
+ const category = productElement.dataset.category || 'Без категории';
650
+ const matchesSearch = !searchTerm || name.includes(searchTerm) || description.includes(searchTerm);
651
+ const matchesCategory = activeCategory === 'all' || category === activeCategory;
652
+ if (matchesSearch && matchesCategory) { productElement.style.display = 'flex'; visibleProducts++; }
653
+ else productElement.style.display = 'none';
654
+ });
655
+ if (visibleProducts === 0 && (searchTerm || activeCategory !== 'all')) {
656
+ const p = document.createElement('p'); p.className = 'no-results-message';
657
+ p.textContent = 'По вашему запросу товары не найдены.'; grid.appendChild(p);
658
+ }
659
+ }
660
+ function setupFilters() {
661
+ const searchInput = document.getElementById('search-input');
662
+ const categoryFilters = document.querySelectorAll('.category-filter');
663
+ if(searchInput) searchInput.addEventListener('input', filterProducts);
664
+ categoryFilters.forEach(filter => {
665
+ filter.addEventListener('click', function() {
666
+ categoryFilters.forEach(f => f.classList.remove('active')); this.classList.add('active'); filterProducts();
667
+ });
668
+ });
669
+ }
670
+
671
+ // --- Notifications ---
672
+ function showNotification(message, duration = 3000) {
673
+ const placeholder = document.getElementById('notification-placeholder'); if (!placeholder) return;
674
+ const notification = document.createElement('div'); notification.className = 'notification'; notification.textContent = message;
675
+ placeholder.appendChild(notification);
676
+ setTimeout(() => { notification.classList.add('show'); }, 10); // Trigger fade in
677
+ setTimeout(() => { notification.classList.remove('show'); setTimeout(() => { notification.remove(); }, 500); }, duration); // Fade out and remove
678
+ }
679
+
680
+ // --- Event Listeners and Initial Setup ---
681
+ document.addEventListener('DOMContentLoaded', () => {
682
+ applyInitialTheme(); attemptAutoLogin(); updateCartButton(); setupFilters();
683
+ window.addEventListener('click', (event) => { if (event.target.classList.contains('modal')) closeModal(event.target.id); });
684
+ window.addEventListener('keydown', (event) => { if (event.key === 'Escape') document.querySelectorAll('.modal[style*="display: block"]').forEach(m => closeModal(m.id)); });
685
+ });
686
+ </script>
687
+ </body>
688
+ </html>
689
+ '''
690
+ return render_template_string(
691
+ catalog_html,
692
+ products=products,
693
+ categories=categories,
694
+ repo_id=REPO_ID,
695
+ is_authenticated=is_authenticated,
696
+ store_address=STORE_ADDRESS,
697
+ session=session,
698
+ currency_code=CURRENCY_CODE,
699
+ whatsapp_number=WHATSAPP_NUMBER # Передаем номер в шаблон
700
+ )
701
+
702
+
703
+ @app.route('/product/<int:index>')
704
+ def product_detail(index):
705
+ """Отдает HTML с деталями одного продукта для модального окна."""
706
+ data = load_data()
707
+ products = data.get('products', [])
708
+ is_authenticated = 'user' in session
709
+ try:
710
+ product = products[index]
711
+ except IndexError:
712
+ logging.warning(f"Попытка доступа к несуществующему продукту с индексом {index}")
713
+ return "Товар не найден", 404
714
+
715
+ # Шаблон деталей товара с обновленными цветами
716
+ detail_html = '''
717
+ <div style="padding: 10px;">
718
+ <h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: var(--primary-green, #1C4A3A);">{{ product['name'] }}</h2>
719
+ {# Swiper Slider #}
720
+ <div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
721
+ <div class="swiper-wrapper">
722
+ {% if product.get('photos') and product['photos']|length > 0 %}
723
+ {% for photo in product['photos'] %}
724
+ <div class="swiper-slide" style="display: flex; justify-content: center; align-items: center; padding: 10px;">
725
+ <div class="swiper-zoom-container">
726
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
727
+ alt="{{ product['name'] }} - фото {{ loop.index }}"
728
+ style="max-width: 100%; max-height: 400px; object-fit: contain; display: block; margin: auto; cursor: grab;">
729
+ </div>
730
+ </div>
731
+ {% endfor %}
732
+ {% else %}
733
+ <div class="swiper-slide" style="display: flex; justify-content: center; align-items: center;">
734
+ <img src="https://via.placeholder.com/400x400.png?text=No+Image" alt="Изображение отсутствует" style="max-width: 100%; max-height: 400px; object-fit: contain;">
735
+ </div>
736
+ {% endif %}
737
+ </div>
738
+ {% if product.get('photos') and product['photos']|length > 1 %}
739
+ <div class="swiper-pagination" style="position: relative; bottom: 5px;"></div>
740
+ <div class="swiper-button-next" style="color: var(--primary-green, #1C4A3A);"></div>
741
+ <div class="swiper-button-prev" style="color: var(--primary-green, #1C4A3A);"></div>
742
+ {% endif %}
743
+ </div>
744
+
745
+ {# Product Details #}
746
+ <div style="margin-top: 20px; font-size: 1rem; line-height: 1.7;">
747
+ <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
748
+ {% if is_authenticated %}
749
+ <p style="font-size: 1.2rem; font-weight: bold; color: var(--primary-green, #1C4A3A);"><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
750
+ {% else %}
751
+ <p><strong>Цена:</strong> <a href="{{ url_for('login') }}" style="color: var(--link-blue, #3b82f6); text-decoration: underline;">Доступна после входа</a></p>
752
+ {% endif %}
753
+ <p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\n', '<br>')|safe }}</p>
754
+ {% set colors = product.get('colors', []) %}
755
+ {% if colors and colors|select('ne', '')|list|length > 0 %}
756
+ <p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p>
757
+ {% endif %}
758
+ </div>
759
+ </div>
760
+ '''
761
+ return render_template_string(
762
+ detail_html,
763
+ product=product,
764
+ repo_id=REPO_ID,
765
+ is_authenticated=is_authenticated,
766
+ currency_code=CURRENCY_CODE
767
+ )
768
+
769
+ # --- Маршруты аутентификации ---
770
+
771
+ # Шаблон для страницы входа с обновленными цветами
772
+ LOGIN_TEMPLATE = '''
773
+ <!DOCTYPE html>
774
+ <html lang="ru">
775
+ <head>
776
+ <meta charset="UTF-8">
777
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
778
+ <title>Вход - Soola Cosmetics</title>
779
+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
780
+ <style>
781
+ :root { --primary-green: #1C4A3A; --primary-green-darker: #113126; --focus-ring-color: rgba(28, 74, 58, 0.3); --link-blue: #3b82f6; --delete-red: #ef4444; }
782
+ body { font-family: 'Poppins', sans-serif; background: linear-gradient(135deg, #e6f1ee, #f0f7f5); display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
783
+ .container { max-width: 400px; width: 100%; background: #fff; padding: 30px 40px; border-radius: 15px; box-shadow: 0 5px 20px rgba(0,0,0,0.1); text-align: center; }
784
+ h2 { color: var(--primary-green); margin-bottom: 25px; font-weight: 600; }
785
+ label { display: block; text-align: left; margin: 15px 0 5px; font-weight: 500; color: #555; }
786
+ input[type="text"], input[type="password"] { width: 100%; padding: 12px; margin-bottom: 15px; border: 1px solid #ddd; border-radius: 8px; box-sizing: border-box; font-size: 1rem; }
787
+ input:focus { border-color: var(--primary-green); outline: none; box-shadow: 0 0 0 3px var(--focus-ring-color); }
788
+ button { width: 100%; padding: 12px; background-color: var(--primary-green); color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; font-weight: 600; transition: background-color 0.3s ease; margin-top: 10px; }
789
+ button:hover { background-color: var(--primary-green-darker); }
790
+ .error { color: var(--delete-red); background-color: #fef2f2; border: 1px solid #fecaca; padding: 10px; border-radius: 8px; margin-bottom: 15px; font-size: 0.9rem; text-align: left;}
791
+ .back-link { display: inline-block; margin-top: 20px; color: var(--link-blue); text-decoration: none; font-size: 0.9rem; }
792
+ .back-link:hover { text-decoration: underline; }
793
+ </style>
794
+ </head>
795
+ <body>
796
+ <div class="container">
797
+ <h2>Вход в Soola Cosmetics</h2>
798
+ {% if error %}
799
+ <p class="error">{{ error }}</p>
800
+ {% endif %}
801
+ <form method="POST">
802
+ <label for="login">Логин:</label>
803
+ <input type="text" id="login" name="login" required>
804
+ <label for="password">Пароль:</label>
805
+ <input type="password" id="password" name="password" required>
806
+ <button type="submit">Войти</button>
807
+ </form>
808
+ <a href="{{ url_for('catalog') }}" class="back-link">← Вернуться в каталог</a>
809
+ </div>
810
+ </body>
811
+ </html>
812
+ '''
813
+
814
+ # Функции login, auto_login, logout остаются без изменений в логике Python
815
+ @app.route('/login', methods=['GET', 'POST'])
816
+ def login():
817
+ if request.method == 'POST':
818
+ login = request.form.get('login')
819
+ password = request.form.get('password')
820
+ if not login or not password:
821
+ return render_template_string(LOGIN_TEMPLATE, error="Логин и пароль не могут быть пустыми."), 400
822
+ users = load_users()
823
+ if login in users and users[login].get('password') == password: # Сравнение паролей (нужно хеширование!)
824
+ user_info = users[login]
825
+ session['user'] = login
826
+ session['user_info'] = {'login': login, 'first_name': user_info.get('first_name', ''),
827
+ 'last_name': user_info.get('last_name', ''), 'country': user_info.get('country', ''),
828
+ 'city': user_info.get('city', '')}
829
+ logging.info(f"Пользователь {login} успешно вошел.")
830
+ login_response_html = f'''<!DOCTYPE html><html><head><title>Перенаправление...</title></head><body><script>try {{ localStorage.setItem('soolaUser', '{login}'); }} catch (e) {{ console.error("LS Error:", e); }} window.location.href = "{url_for('catalog')}";</script><p>Вход выполнен. Перенаправление...</p></body></html>'''
831
+ return login_response_html
832
+ else:
833
+ logging.warning(f"Неудачная попытка входа для {login}.")
834
+ return render_template_string(LOGIN_TEMPLATE, error="Неверный логин или пароль."), 401
835
+ return render_template_string(LOGIN_TEMPLATE, error=None)
836
+
837
+ @app.route('/auto_login', methods=['POST'])
838
+ def auto_login():
839
+ data = request.get_json(); login = data.get('login') if data else None
840
+ if not login: return "Логин не предоставлен", 400
841
+ users = load_users()
842
+ if login in users:
843
+ user_info = users[login]
844
+ session['user'] = login
845
+ session['user_info'] = {'login': login, 'first_name': user_info.get('first_name', ''),
846
+ 'last_name': user_info.get('last_name', ''), 'country': user_info.get('country', ''),
847
+ 'city': user_info.get('city', '')}
848
+ logging.info(f"Авто-вход для {login} выполнен.")
849
+ return "OK", 200
850
+ else: return "Ошибка авто-входа", 400 # Не раскрываем существование пользователя
851
+
852
+ @app.route('/logout')
853
+ def logout():
854
+ logged_out_user = session.pop('user', None); session.pop('user_info', None)
855
+ if logged_out_user: logging.info(f"Пользователь {logged_out_user} вышел.")
856
+ logout_response_html = '''<!DOCTYPE html><html><head><title>Выход...</title></head><body><script>try { localStorage.removeItem('soolaUser'); } catch (e) { console.error("LS Error:", e); } window.location.href = "/";</script><p>Выход выполнен. Перенаправление...</p></body></html>'''
857
+ return logout_response_html
858
+
859
+ # --- Админ-панель ---
860
+ # Шаблон админ-панели с обновленными цветами
861
+ ADMIN_TEMPLATE = '''
862
+ <!DOCTYPE html>
863
+ <html lang="ru">
864
+ <head>
865
+ <meta charset="UTF-8">
866
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
867
+ <title>Админ-панель - Soola Cosmetics</title>
868
+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
869
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
870
+ <style>
871
+ :root {
872
+ --primary-green: #1C4A3A; --primary-green-darker: #113126;
873
+ --accent-green: #38a169; --accent-green-darker: #2f855a;
874
+ --delete-red: #ef4444; --delete-red-darker: #dc2626;
875
+ --link-blue: #3b82f6; --link-blue-darker: #2563eb;
876
+ --light-bg: #f4f7f6; --light-text: #333;
877
+ --card-bg: #fff; --border-color: #eee;
878
+ --focus-ring-color: rgba(28, 74, 58, 0.3);
879
+ }
880
+ body { font-family: 'Poppins', sans-serif; background-color: var(--light-bg); color: var(--light-text); padding: 20px; line-height: 1.6; }
881
+ .container { max-width: 1200px; margin: 0 auto; background-color: var(--card-bg); padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
882
+ .header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
883
+ h1, h2, h3 { font-weight: 600; color: var(--primary-green); margin-bottom: 15px; }
884
+ h1 { font-size: 1.8rem; }
885
+ h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
886
+ h3 { font-size: 1.2rem; color: var(--primary-green-darker); margin-top: 20px; }
887
+ .section { margin-bottom: 30px; padding: 20px; background-color: #fdfdfe; border: 1px solid var(--border-color); border-radius: 8px; }
888
+ form { margin-bottom: 20px; }
889
+ label { font-weight: 500; margin-top: 10px; display: block; color: #555; font-size: 0.9rem;}
890
+ input[type="text"], input[type="number"], input[type="password"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; }
891
+ input:focus, textarea:focus, select:focus { border-color: var(--primary-green); outline: none; box-shadow: 0 0 0 3px var(--focus-ring-color); }
892
+ textarea { min-height: 80px; resize: vertical; }
893
+ input[type="file"] { padding: 8px; background-color: #f9f9f9; cursor: pointer; border: 1px solid #ddd; border-radius: 6px;}
894
+ input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #eee; border: 1px solid #ccc; cursor: pointer; margin-right: 10px; font-size: 0.9rem;}
895
+ button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: var(--primary-green); color: white; font-weight: 500; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 0.95rem; display: inline-flex; align-items: center; gap: 5px; text-decoration: none; line-height: 1.2;}
896
+ button:hover, .button:hover { background-color: var(--primary-green-darker); }
897
+ button:active, .button:active { transform: scale(0.98); }
898
+ button[type="submit"] { min-width: 120px; justify-content: center; }
899
+ .delete-button { background-color: var(--delete-red); } /* Красный для удаления */
900
+ .delete-button:hover { background-color: var(--delete-red-darker); }
901
+ .add-button { background-color: var(--accent-green); } /* Акцентный зеленый для добавления */
902
+ .add-button:hover { background-color: var(--accent-green-darker); }
903
+ .info-button { background-color: var(--link-blue); } /* Синий для инфо/ссылок */
904
+ .info-button:hover { background-color: var(--link-blue-darker); }
905
+ .item-list { display: grid; gap: 20px; }
906
+ .item { background: var(--card-bg); padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid var(--border-color); }
907
+ .item p { margin: 5px 0; font-size: 0.9rem; color: #444; }
908
+ .item strong { color: #333; }
909
+ .item .description { font-size: 0.85rem; color: #666; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
910
+ .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
911
+ .edit-form-container { margin-top: 15px; padding: 20px; background: #f9fafb; border: 1px dashed #ddd; border-radius: 6px; display: none; /* Скрыто по умолчанию */ }
912
+ details { background-color: #fdfdfe; border: 1px solid var(--border-color); border-radius: 8px; margin-bottom: 20px; }
913
+ details > summary { cursor: pointer; font-weight: 600; color: var(--primary-green-darker); display: block; padding: 15px; border-bottom: 1px solid var(--border-color); list-style: none; position: relative; transition: background-color 0.2s ease;}
914
+ details > summary:hover { background-color: #f0f7f5; }
915
+ details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; }
916
+ details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
917
+ details[open] > summary { border-bottom: 1px solid var(--border-color); background-color: #e6f1ee;}
918
+ details .form-content { padding: 20px; }
919
+ .color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
920
+ .color-input-group input { flex-grow: 1; margin: 0; }
921
+ .remove-color-btn { background-color: #f56565; /* Светлее красный */ color: white; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; border: none; border-radius: 4px; cursor: pointer;}
922
+ .remove-color-btn:hover { background-color: var(--delete-red); }
923
+ .photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #eee; object-fit: cover;}
924
+ .sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
925
+ .flex-container { display: flex; flex-wrap: wrap; gap: 20px; }
926
+ .flex-item { flex: 1; min-width: 350px; /* Минимальная ширина колонки */ }
927
+ .message { padding: 12px 15px; border-radius: 6px; margin-bottom: 20px; font-size: 0.95rem; border: 1px solid transparent; display: flex; align-items: center; gap: 10px;}
928
+ .message i { font-size: 1.1rem; }
929
+ .message.success { background-color: #d1fae5; color: #065f46; border-color: #a7f3d0;} /* Зеленый успех */
930
+ .message.error { background-color: #fee2e2; color: #991b1b; border-color: #fecaca;} /* Красная ошибка */
931
+ .message.warning { background-color: #fffbeb; color: #b45309; border-color: #fde68a;} /* Желтое предупреждение */
932
+ </style>
933
+ </head>
934
+ <body>
935
+ <div class="container">
936
+ <div class="header">
937
+ <h1><i class="fas fa-tools" style="color: var(--primary-green);"></i> Админ-панель Soola Cosmetics</h1>
938
+ <a href="{{ url_for('catalog') }}" class="button info-button"><i class="fas fa-store"></i> Перейти в каталог</a>
939
+ </div>
940
+
941
+ {# Сообщения #}
942
+ {% with messages = get_flashed_messages(with_categories=true) %}
943
+ {% if messages %}
944
+ {% for category, message in messages %}
945
+ <div class="message {{ category }}">
946
+ {% if category == 'success' %}<i class="fas fa-check-circle"></i>
947
+ {% elif category == 'error' %}<i class="fas fa-times-circle"></i>
948
+ {% elif category == 'warning' %}<i class="fas fa-exclamation-triangle"></i>
949
+ {% else %}<i class="fas fa-info-circle"></i>{% endif %}
950
+ {{ message }}
951
+ </div>
952
+ {% endfor %}
953
+ {% endif %}
954
+ {% endwith %}
955
+
956
+ <div class="section">
957
+ <h2><i class="fas fa-sync-alt"></i> Синхронизация с Hugging Face</h2>
958
+ <div class="sync-buttons">
959
+ <form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;" onsubmit="return confirm('Загрузить локальные данные на сервер? Это перезапишет данные на сервере.');">
960
+ <button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить на HF</button>
961
+ </form>
962
+ <form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Скачать данные с сервера? Это перезапишет ваши локальные файлы.');">
963
+ {# Используем серый для скачивания #}
964
+ <button type="submit" class="button" style="background-color: #718096;" title="Скачать файлы с Hugging Face (перезапишет локальные)"><i class="fas fa-download"></i> Скачать с HF</button>
965
+ </form>
966
+ </div>
967
+ <p style="font-size: 0.85rem; color: #666;">Резервное копирование на Hugging Face происходит автоматически каждые 30 минут и после сохранений. Используйте кнопки для немедленной синхронизации.</p>
968
+ </div>
969
+
970
+ <div class="flex-container">
971
+ <div class="flex-item"> {# Категории #}
972
+ <div class="section">
973
+ <h2><i class="fas fa-tags"></i> Управление категориями</h2>
974
+ <details>
975
+ <summary><i class="fas fa-plus-circle"></i> Добавить новую категорию</summary>
976
+ <div class="form-content">
977
+ <form method="POST">
978
+ <input type="hidden" name="action" value="add_category">
979
+ <label for="add_category_name">Название новой категории:</label>
980
+ <input type="text" id="add_category_name" name="category_name" required>
981
+ <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button>
982
+ </form>
983
+ </div>
984
+ </details>
985
+
986
+ <h3>Существующие категории:</h3>
987
+ {% if categories %}
988
+ <div class="item-list">
989
+ {% for category in categories %}
990
+ <div class="item" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 15px;">
991
+ <span>{{ category }}</span>
992
+ <form method="POST" style="margin: 0;" onsubmit="return confirm('Удалить категорию \'{{ category }}\'?');">
993
+ <input type="hidden" name="action" value="delete_category">
994
+ <input type="hidden" name="category_name" value="{{ category }}">
995
+ <button type="submit" class="delete-button" style="padding: 5px 10px; font-size: 0.8rem; margin: 0;"><i class="fas fa-trash-alt"></i></button>
996
+ </form>
997
+ </div>
998
+ {% endfor %}
999
+ </div>
1000
+ {% else %}
1001
+ <p>Категорий пока нет.</p>
1002
+ {% endif %}
1003
+ </div>
1004
+ </div>
1005
+
1006
+ <div class="flex-item"> {# Пользователи #}
1007
+ <div class="section">
1008
+ <h2><i class="fas fa-users"></i> Управление пользователями</h2>
1009
+ <details>
1010
+ <summary><i class="fas fa-user-plus"></i> Добавить нового пользователя</summary>
1011
+ <div class="form-content">
1012
+ <form method="POST">
1013
+ <input type="hidden" name="action" value="add_user">
1014
+ <label for="login">Логин *:</label>
1015
+ <input type="text" id="login" name="login" required>
1016
+ <label for="password">Пароль *:</label>
1017
+ <input type="password" id="password" name="password" required title="Пароль будет сохранен в открытом виде.">
1018
+ <p style="font-size: 0.8rem; color: #777;">Внимание: пароль хранится в незашифрованном виде.</p>
1019
+ <label for="first_name">Имя:</label> <input type="text" id="first_name" name="first_name">
1020
+ <label for="last_name">Фамилия:</label> <input type="text" id="last_name" name="last_name">
1021
+ <label for="country">Страна:</label> <input type="text" id="country" name="country">
1022
+ <label for="city">Город:</label> <input type="text" id="city" name="city">
1023
+ <button type="submit" class="add-button"><i class="fas fa-save"></i> Сохранить</button>
1024
+ </form>
1025
+ </div>
1026
+ </details>
1027
+
1028
+ <h3>Список пользователей:</h3>
1029
+ {% if users %}
1030
+ <div class="item-list">
1031
+ {% for login, user_data in users.items() %}
1032
+ <div class="item">
1033
+ <p><strong>Логин:</strong> {{ login }}</p>
1034
+ <p><strong>Имя:</strong> {{ user_data.get('first_name', 'N/A') }} {{ user_data.get('last_name', '') }}</p>
1035
+ <p><strong>Локация:</strong> {{ user_data.get('city', 'N/A') }}, {{ user_data.get('country', 'N/A') }}</p>
1036
+ <div class="item-actions">
1037
+ <form method="POST" style="margin: 0;" onsubmit="return confirm('Удалить пользователя \'{{ login }}\'?');">
1038
+ <input type="hidden" name="action" value="delete_user">
1039
+ <input type="hidden" name="login" value="{{ login }}">
1040
+ <button type="submit" class="delete-button"><i class="fas fa-user-slash"></i> Удалить</button>
1041
+ </form>
1042
+ </div>
1043
+ </div>
1044
+ {% endfor %}
1045
+ </div>
1046
+ {% else %}
1047
+ <p>Пользователей пока нет.</p>
1048
+ {% endif %}
1049
+ </div>
1050
+ </div>
1051
+ </div>
1052
+
1053
+ <div class="section"> {# Товары #}
1054
+ <h2><i class="fas fa-box-open"></i> Управление товарами</h2>
1055
+ <details>
1056
+ <summary><i class="fas fa-plus-circle"></i> Добавить новый товар</summary>
1057
+ <div class="form-content">
1058
+ <form method="POST" enctype="multipart/form-data">
1059
+ <input type="hidden" name="action" value="add_product">
1060
+ <label for="add_name">Название товара *:</label> <input type="text" id="add_name" name="name" required>
1061
+ <label for="add_price">Цена ({{ currency_code }}) *:</label> <input type="number" id="add_price" name="price" step="0.01" min="0" required>
1062
+ <label for="add_description">Описание:</label> <textarea id="add_description" name="description" rows="4"></textarea>
1063
+ <label for="add_category">Категория:</label>
1064
+ <select id="add_category" name="category">
1065
+ <option value="Без категории">Без категории</option>
1066
+ {% for category in categories %}<option value="{{ category }}">{{ category }}</option>{% endfor %}
1067
+ </select>
1068
+ <label for="add_photos">Фотографии (до 10 шт.):</label> <input type="file" id="add_photos" name="photos" accept="image/*" multiple>
1069
+ <label>Цвета/Варианты:</label>
1070
+ <div id="add-color-inputs">
1071
+ <div class="color-input-group">
1072
+ <input type="text" name="colors" placeholder="Например: Зеленый">
1073
+ <button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
1074
+ </div>
1075
+ </div>
1076
+ <button type="button" class="button info-button" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле цвета</button>
1077
+ <br>
1078
+ <button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button>
1079
+ </form>
1080
+ </div>
1081
+ </details>
1082
+
1083
+ <h3>Список товаров:</h3>
1084
+ {% if products %}
1085
+ <div class="item-list">
1086
+ {% for product in products %}
1087
+ <div class="item">
1088
+ <div style="display: flex; gap: 15px; align-items: flex-start;">
1089
+ <div class="photo-preview" style="flex-shrink: 0;">
1090
+ {% if product.get('photos') %}<a href="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" target="_blank"><img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" alt="Фото"></a>
1091
+ {% else %}<img src="https://via.placeholder.com/70x70.png?text=N/A" alt="Нет фото">{% endif %}
1092
+ </div>
1093
+ <div style="flex-grow: 1;">
1094
+ <h3 style="margin-top: 0; margin-bottom: 5px; color: var(--light-text);">{{ product['name'] }}</h3>
1095
+ <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
1096
+ <p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
1097
+ <p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
1098
+ {% set colors = product.get('colors', []) %}<p><strong>Цвета/Вар-ты:</strong> {{ colors|select('ne', '')|join(', ') if colors|select('ne', '')|list|length > 0 else 'Нет' }}</p>
1099
+ {% if product.get('photos') and product['photos']|length > 1 %}<p style="font-size: 0.8rem; color: #666;">(Фото: {{ product['photos']|length }})</p>{% endif %}
1100
+ </div>
1101
+ </div>
1102
+ <div class="item-actions">
1103
+ <button type="button" class="button info-button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редакт.</button>
1104
+ <form method="POST" style="margin:0;" onsubmit="return confirm('Удалить товар \'{{ product['name'] }}\'?');">
1105
+ <input type="hidden" name="action" value="delete_product"><input type="hidden" name="index" value="{{ loop.index0 }}">
1106
+ <button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
1107
+ </form>
1108
+ </div>
1109
+ <div id="edit-form-{{ loop.index0 }}" class="edit-form-container">
1110
+ <h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
1111
+ <form method="POST" enctype="multipart/form-data">
1112
+ <input type="hidden" name="action" value="edit_product"><input type="hidden" name="index" value="{{ loop.index0 }}">
1113
+ <label>Название *:</label> <input type="text" name="name" value="{{ product['name'] }}" required>
1114
+ <label>Цена ({{ currency_code }}) *:</label> <input type="number" name="price" step="0.01" min="0" value="{{ product['price'] }}" required>
1115
+ <label>Описание:</label> <textarea name="description" rows="4">{{ product.get('description', '') }}</textarea>
1116
+ <label>Категория:</label>
1117
+ <select name="category">
1118
+ <option value="Без категории" {% if product.get('category', 'Без категории') == 'Без категории' %}selected{% endif %}>Без категории</option>
1119
+ {% for category in categories %}<option value="{{ category }}" {% if product.get('category') == category %}selected{% endif %}>{{ category }}</option>{% endfor %}
1120
+ </select>
1121
+ <label>Заменить фото (выберите новые, до 10):</label> <input type="file" name="photos" accept="image/*" multiple>
1122
+ {% if product.get('photos') %}<p style="font-size: 0.85rem; margin-top: 5px;">Текущие фото:</p><div class="photo-preview">{% for photo in product['photos'] %}<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}" alt="Фото {{ loop.index }}">{% endfor %}</div>{% endif %}
1123
+ <label>Цвета/Варианты:</label>
1124
+ <div id="edit-color-inputs-{{ loop.index0 }}">
1125
+ {% set current_colors = product.get('colors', []) %}
1126
+ {% if current_colors and current_colors|select('ne', '')|list|length > 0 %}
1127
+ {% for color in current_colors %}{% if color.strip() %}<div class="color-input-group"><input type="text" name="colors" value="{{ color }}"><button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button></div>{% endif %}{% endfor %}
1128
+ {% else %}<div class="color-input-group"><input type="text" name="colors" placeholder="Например: Зеленый"><button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button></div>{% endif %}
1129
+ </div>
1130
+ <button type="button" class="button info-button" style="margin-top: 5px;" onclick="addColorInput('edit-color-inputs-{{ loop.index0 }}')"><i class="fas fa-palette"></i> Добавить поле цвета</button>
1131
+ <br><button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button>
1132
+ </form>
1133
+ </div>
1134
+ </div>
1135
+ {% endfor %}
1136
+ </div>
1137
+ {% else %} <p>Товаров пока нет.</p> {% endif %}
1138
+ </div>
1139
+ </div>
1140
+ <script>
1141
+ function toggleEditForm(formId) { const el = document.getElementById(formId); if (el) el.style.display = el.style.display === 'none' || el.style.display === '' ? 'block' : 'none'; }
1142
+ function addColorInput(containerId) { const cont = document.getElementById(containerId); if (!cont) return; const div = document.createElement('div'); div.className = 'color-input-group'; div.innerHTML = \`<input type="text" name="colors" placeholder="Новый цвет/вариант"><button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>\`; cont.appendChild(div); div.querySelector('input')?.focus(); }
1143
+ function removeColorInput(button) { button.closest('.color-input-group')?.remove(); }
1144
+ </script>
1145
+ </body>
1146
+ </html>
1147
+ '''
1148
+
1149
+ # Функция admin остается без изменений в логике Python
1150
+ @app.route('/admin', methods=['GET', 'POST'])
1151
+ def admin():
1152
+ data = load_data(); products = data.get('products', []); categories = data.get('categories', []); users = load_users()
1153
+ if request.method == 'POST':
1154
+ action = request.form.get('action'); logging.info(f"Admin action: {action}")
1155
+ try:
1156
+ # --- Обработка категори�� ---
1157
+ if action == 'add_category':
1158
+ name = request.form.get('category_name', '').strip()
1159
+ if name and name not in categories: categories.append(name); categories.sort(); save_data(data); flash(f"Категория '{name}' добавлена.", 'success')
1160
+ elif not name: flash("Название категории пустое.", 'error')
1161
+ else: flash(f"Категория '{name}' уже существует.", 'error')
1162
+ elif action == 'delete_category':
1163
+ name = request.form.get('category_name')
1164
+ if name and name in categories:
1165
+ categories.remove(name); count = 0
1166
+ for p in products:
1167
+ if p.get('category') == name: p['category'] = 'Без категории'; count += 1
1168
+ save_data(data); flash(f"Категория '{name}' удалена (затронуто товаров: {count}).", 'success')
1169
+ else: flash(f"Не удалось удалить категорию '{name}'.", 'error')
1170
+
1171
+ # --- Обработка товаров ---
1172
+ elif action == 'add_product' or action == 'edit_product':
1173
+ is_edit = action == 'edit_product'; index = -1
1174
+ if is_edit:
1175
+ try: index = int(request.form.get('index', -1)); product = products[index]
1176
+ except (ValueError, IndexError): flash("Ошибка: неверный индекс товара.", 'error'); return redirect(url_for('admin'))
1177
+ name = request.form.get('name', '').strip(); price_str = request.form.get('price', '').replace(',', '.')
1178
+ if not name or not price_str: flash("Название и цена обязательны.", 'error'); return redirect(url_for('admin'))
1179
+ try: price = round(float(price_str), 2); price = max(0, price)
1180
+ except ValueError: flash("Неверный формат цены.", 'error'); return redirect(url_for('admin'))
1181
+ description = request.form.get('description', '').strip(); category = request.form.get('category')
1182
+ category = category if category in categories else 'Без категории'
1183
+ colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
1184
+ photos_files = request.files.getlist('photos'); new_photos_list = []
1185
+ if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
1186
+ uploads_dir = 'uploads_temp'; os.makedirs(uploads_dir, exist_ok=True); api = HfApi()
1187
+ limit = 10; count = 0
1188
+ for photo in photos_files:
1189
+ if count >= limit: flash(f"Загружено только первые {limit} фото.", "warning"); break
1190
+ if photo and photo.filename:
1191
+ try:
1192
+ ext = os.path.splitext(photo.filename)[1].lower()
1193
+ if ext not in ['.png', '.jpg', '.jpeg', '.webp', '.gif']: continue # Проверка расширения
1194
+ fname = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
1195
+ tpath = os.path.join(uploads_dir, fname); photo.save(tpath)
1196
+ api.upload_file(path_or_fileobj=tpath, path_in_repo=f"photos/{fname}", repo_id=REPO_ID,
1197
+ repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"{'Add' if not is_edit else 'Update'} photo for {name}")
1198
+ new_photos_list.append(fname); os.remove(tpath); count += 1
1199
+ except Exception as e: logging.error(f"Ошибка загрузки фото {photo.filename}: {e}"); flash(f"Ошибка загрузки фото {photo.filename}.", 'error')
1200
+ # Обновляем или добавляем товар
1201
+ if is_edit:
1202
+ products[index].update({'name': name, 'price': price, 'description': description, 'category': category, 'colors': colors})
1203
+ if new_photos_list: products[index]['photos'] = new_photos_list # Заменяем фото только если загружены новые
1204
+ flash(f"Товар '{name}' обновлен.", 'success')
1205
+ else:
1206
+ products.append({'name': name, 'price': price, 'description': description, 'category': category, 'photos': new_photos_list, 'colors': colors})
1207
+ flash(f"Товар '{name}' добавлен.", 'success')
1208
+ products.sort(key=lambda x: x.get('name', '').lower()); save_data(data)
1209
+ elif action == 'delete_product':
1210
+ try: index = int(request.form.get('index', -1)); deleted = products.pop(index); name = deleted.get('name', 'N/A')
1211
+ # TODO: Удалить фото с HF? (сложно без ID фото)
1212
+ save_data(data); flash(f"Товар '{name}' удален.", 'success')
1213
+ except (ValueError, IndexError): flash("Ошибка: неверный индекс товара.", 'error')
1214
+
1215
+ # --- Обработка пользователей ---
1216
+ elif action == 'add_user':
1217
+ login = request.form.get('login', '').strip(); password = request.form.get('password', '').strip()
1218
+ if not login or not password: flash("Логин и пароль пользователя обязательны.", 'error')
1219
+ elif login in users: flash(f"Пользователь '{login}' уже существует.", 'error')
1220
+ else:
1221
+ # ВНИМАНИЕ: Хранение пароля в открытом виде! Нужен HASH!
1222
+ users[login] = {'password': password, 'first_name': request.form.get('first_name', '').strip(),
1223
+ 'last_name': request.form.get('last_name', '').strip(), 'country': request.form.get('country', '').strip(),
1224
+ 'city': request.form.get('city', '').strip()}
1225
+ save_users(users); flash(f"Пользователь '{login}' добавлен.", 'success')
1226
+ elif action == 'delete_user':
1227
+ login = request.form.get('login')
1228
+ if login and login in users: del users[login]; save_users(users); flash(f"Пользователь '{login}' удален.", 'success')
1229
+ else: flash(f"Не удалось удалить пользователя '{login}'.", 'error')
1230
+ else: flash(f"Неизвестное действие: {action}", 'warning')
1231
+ return redirect(url_for('admin')) # Редирект после POST
1232
+ except Exception as e:
1233
+ logging.error(f"Ошибка в админке ({action}): {e}", exc_info=True)
1234
+ flash(f"Произошла внутренняя ошибка: {e}", 'error')
1235
+ return redirect(url_for('admin'))
1236
+
1237
+ # GET запрос
1238
+ products.sort(key=lambda x: x.get('name', '').lower()); categories.sort(); sorted_users = dict(sorted(users.items()))
1239
+ return render_template_string(ADMIN_TEMPLATE, products=products, categories=categories, users=sorted_users,
1240
+ repo_id=REPO_ID, currency_code=CURRENCY_CODE)
1241
+
1242
+
1243
+ # --- Маршруты для принудительной синхронизации ---
1244
+ @app.route('/force_upload', methods=['POST'])
1245
+ def force_upload():
1246
+ logging.info("Запуск принудительной загрузки на HF...")
1247
+ try: upload_db_to_hf(); flash("Данные загружены на Hugging Face.", 'success')
1248
+ except Exception as e: logging.error(f"Ошибка принудительной загрузки: {e}"); flash(f"Ошибка загрузки: {e}", 'error')
1249
+ return redirect(url_for('admin'))
1250
+
1251
+ @app.route('/force_download', methods=['POST'])
1252
+ def force_download():
1253
+ logging.info("Запуск принудительного скачивания с HF...")
1254
+ try: download_db_from_hf(); flash("Данные скачаны с Hugging Face.", 'success')
1255
+ except Exception as e: logging.error(f"Ошибка принудительного скачивания: {e}"); flash(f"Ошибка скачивания: {e}", 'error')
1256
+ return redirect(url_for('admin'))
1257
+
1258
+ # --- Запуск приложения ---
1259
+ if __name__ == '__main__':
1260
+ load_data(); load_users() # Инициализация данных при старте
1261
+ if HF_TOKEN_WRITE and REPO_ID: # Запускаем бэкап только если есть токен и ID репо
1262
+ backup_thread = threading.Thread(target=periodic_backup, daemon=True); backup_thread.start()
1263
+ logging.info("Поток периодического резервного копирования запущен.")
1264
+ else: logging.warning("Периодическое резервное копирование НЕ запущено (нет HF_TOKEN_WRITE и/или HF_REPO_ID).")
1265
+ port = int(os.environ.get('PORT', 7860))
1266
+ logging.info(f"Запуск Flask приложения на http://0.0.0.0:{port}")
1267
+ # Для разработки можно использовать debug=True, для продакшена - False
1268
+ # Используйте WSGI сервер (Gunicorn, Waitress) для продакшена!
1269
+ app.run(debug=False, host='0.0.0.0', port=port)
1270
+