Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,6 +1,4 @@
|
|
| 1 |
|
| 2 |
-
# --- START OF FILE app.py ---
|
| 3 |
-
|
| 4 |
from flask import Flask, render_template_string, request, redirect, url_for, send_file, flash, jsonify
|
| 5 |
import json
|
| 6 |
import os
|
|
@@ -14,42 +12,28 @@ from werkzeug.utils import secure_filename
|
|
| 14 |
from dotenv import load_dotenv
|
| 15 |
import requests
|
| 16 |
import uuid
|
| 17 |
-
|
| 18 |
load_dotenv()
|
| 19 |
-
|
| 20 |
app = Flask(__name__)
|
| 21 |
app.secret_key = 'your_unique_secret_key_soola_cosmetics_67890_no_login'
|
| 22 |
DATA_FILE = 'data.json'
|
| 23 |
-
|
| 24 |
-
|
| 25 |
SYNC_FILES = [DATA_FILE]
|
| 26 |
-
|
| 27 |
REPO_ID = "Kgshop/nizhbel"
|
| 28 |
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
|
| 29 |
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 30 |
-
|
| 31 |
STORE_ADDRESS = "Рынок Кербент, 6 ряд , 3 контейнер / 5 ряд 25 контейнер "
|
| 32 |
-
|
| 33 |
CURRENCY_CODE = 'KGS'
|
| 34 |
CURRENCY_NAME = 'Кыргызский сом'
|
| 35 |
-
|
| 36 |
DOWNLOAD_RETRIES = 3
|
| 37 |
-
DOWNLOAD_DELAY = 5
|
| 38 |
-
|
| 39 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 40 |
-
|
| 41 |
-
# --- Hugging Face Sync Functions ---
|
| 42 |
-
|
| 43 |
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
|
| 44 |
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
|
| 45 |
logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
|
| 46 |
-
|
| 47 |
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
|
| 48 |
-
|
| 49 |
files_to_download = [specific_file] if specific_file else SYNC_FILES
|
| 50 |
logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
|
| 51 |
all_successful = True
|
| 52 |
-
|
| 53 |
for file_name in files_to_download:
|
| 54 |
success = False
|
| 55 |
for attempt in range(retries + 1):
|
|
@@ -90,17 +74,13 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
|
|
| 90 |
logging.error(f"Network error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
|
| 91 |
except Exception as e:
|
| 92 |
logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
|
| 93 |
-
|
| 94 |
if attempt < retries:
|
| 95 |
time.sleep(delay)
|
| 96 |
-
|
| 97 |
if not success:
|
| 98 |
logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
|
| 99 |
all_successful = False
|
| 100 |
-
|
| 101 |
logging.info(f"Download process finished. Overall success: {all_successful}")
|
| 102 |
return all_successful
|
| 103 |
-
|
| 104 |
def upload_db_to_hf(specific_file=None):
|
| 105 |
if not HF_TOKEN_WRITE:
|
| 106 |
logging.warning("HF_TOKEN (for writing) not set. Skipping upload to Hugging Face.")
|
|
@@ -109,7 +89,6 @@ def upload_db_to_hf(specific_file=None):
|
|
| 109 |
api = HfApi()
|
| 110 |
files_to_upload = [specific_file] if specific_file else SYNC_FILES
|
| 111 |
logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
|
| 112 |
-
|
| 113 |
for file_name in files_to_upload:
|
| 114 |
if os.path.exists(file_name):
|
| 115 |
try:
|
|
@@ -129,7 +108,6 @@ def upload_db_to_hf(specific_file=None):
|
|
| 129 |
logging.info("Finished uploading files to HF.")
|
| 130 |
except Exception as e:
|
| 131 |
logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
|
| 132 |
-
|
| 133 |
def periodic_backup():
|
| 134 |
backup_interval = 1800
|
| 135 |
logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
|
|
@@ -138,9 +116,6 @@ def periodic_backup():
|
|
| 138 |
logging.info("Starting periodic backup...")
|
| 139 |
upload_db_to_hf()
|
| 140 |
logging.info("Periodic backup finished.")
|
| 141 |
-
|
| 142 |
-
# --- Data Loading and Saving Functions ---
|
| 143 |
-
|
| 144 |
def load_data():
|
| 145 |
default_data = {'products': [], 'categories': [], 'orders': {}}
|
| 146 |
try:
|
|
@@ -158,7 +133,6 @@ def load_data():
|
|
| 158 |
logging.warning(f"Local file {DATA_FILE} not found. Attempting download from HF.")
|
| 159 |
except json.JSONDecodeError:
|
| 160 |
logging.error(f"Error decoding JSON in local {DATA_FILE}. File might be corrupt. Attempting download.")
|
| 161 |
-
|
| 162 |
if download_db_from_hf(specific_file=DATA_FILE):
|
| 163 |
try:
|
| 164 |
with open(DATA_FILE, 'r', encoding='utf-8') as file:
|
|
@@ -190,7 +164,6 @@ def load_data():
|
|
| 190 |
except Exception as create_e:
|
| 191 |
logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}")
|
| 192 |
return default_data
|
| 193 |
-
|
| 194 |
def save_data(data):
|
| 195 |
try:
|
| 196 |
if not isinstance(data, dict):
|
|
@@ -199,112 +172,107 @@ def save_data(data):
|
|
| 199 |
if 'products' not in data: data['products'] = []
|
| 200 |
if 'categories' not in data: data['categories'] = []
|
| 201 |
if 'orders' not in data: data['orders'] = {}
|
| 202 |
-
|
| 203 |
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 204 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 205 |
logging.info(f"Data successfully saved to {DATA_FILE}")
|
| 206 |
upload_db_to_hf(specific_file=DATA_FILE)
|
| 207 |
except Exception as e:
|
| 208 |
logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
|
| 209 |
-
|
| 210 |
-
# --- Templates ---
|
| 211 |
-
|
| 212 |
CATALOG_TEMPLATE = '''
|
| 213 |
<!DOCTYPE html>
|
| 214 |
<html lang="ru">
|
| 215 |
<head>
|
| 216 |
<meta charset="UTF-8">
|
| 217 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 218 |
-
<title>
|
| 219 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 220 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 221 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
|
| 222 |
<style>
|
| 223 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 224 |
-
body { font-family: 'Poppins', sans-serif; background: #
|
| 225 |
-
body.dark-mode { background: #
|
| 226 |
.container { max-width: 1300px; margin: 0 auto; padding: 20px; }
|
| 227 |
-
.header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #
|
| 228 |
-
body.dark-mode .header { border-bottom-color: #
|
| 229 |
-
.header h1 { font-size: 1.8rem; font-weight: 600; color: #
|
| 230 |
-
.theme-toggle { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #
|
| 231 |
-
.theme-toggle:hover { color: #
|
| 232 |
-
body.dark-mode .theme-toggle { color: #
|
| 233 |
-
body.dark-mode .theme-toggle:hover { color: #
|
| 234 |
-
.store-address { padding: 15px; text-align: center; background-color: #ffffff; margin: 20px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); font-size: 1rem; color: #
|
| 235 |
-
body.dark-mode .store-address { background-color: #
|
| 236 |
.filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
| 237 |
.search-container { margin: 20px 0; text-align: center; }
|
| 238 |
-
#search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid #
|
| 239 |
-
body.dark-mode #search-input { background-color: #
|
| 240 |
-
#search-input:focus { border-color: #
|
| 241 |
-
body.dark-mode #search-input:focus { border-color: #
|
| 242 |
-
.category-filter { padding: 8px 16px; border: 1px solid #
|
| 243 |
-
body.dark-mode .category-filter { background-color: #
|
| 244 |
-
.category-filter.active, .category-filter:hover { background-color: #
|
| 245 |
-
body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover { background-color: #
|
| 246 |
.products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 20px; padding: 10px; }
|
| 247 |
@media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } }
|
| 248 |
@media (min-width: 900px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
|
| 249 |
-
|
| 250 |
-
.product { background: #
|
| 251 |
-
body.dark-mode .product { background: #412539; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #59344f; }
|
| 252 |
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
|
| 253 |
body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
|
| 254 |
.product-image { width: 100%; aspect-ratio: 1 / 1; background-color: #fff; border-radius: 10px 10px 0 0; overflow: hidden; display: flex; justify-content: center; align-items: center; margin-bottom: 0; }
|
| 255 |
.product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; }
|
| 256 |
.product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
|
| 257 |
-
.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: #
|
| 258 |
-
body.dark-mode .product h2 { color: #
|
| 259 |
-
.product-price { font-size: 1.2rem; color: #
|
| 260 |
-
body.dark-mode .product-price { color: #
|
| 261 |
-
.product-description { font-size: 0.85rem; color: #
|
| 262 |
-
body.dark-mode .product-description { color: #
|
| 263 |
.product-actions { padding: 0 15px 15px 15px; display: flex; flex-direction: column; gap: 8px; }
|
| 264 |
-
.product-button { display: block; width: 100%; padding: 10px; border: none; border-radius: 8px; background-color: #
|
| 265 |
-
.product-button:hover { background-color: #
|
| 266 |
.product-button i { margin-right: 5px; }
|
| 267 |
-
.add-to-cart { background-color: #
|
| 268 |
-
.add-to-cart:hover { background-color: #
|
| 269 |
-
#cart-button { position: fixed; bottom: 25px; right: 25px; background-color: #
|
| 270 |
#cart-button .fa-shopping-cart { margin-right: 0; }
|
| 271 |
-
#cart-button span { position: absolute; top: -5px; right: -5px; background-color: #
|
| 272 |
.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; }
|
| 273 |
.modal-content { background: #ffffff; 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; }
|
| 274 |
-
body.dark-mode .modal-content { background: #
|
| 275 |
@keyframes slideIn { from { transform: translateY(-30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
| 276 |
.close { position: absolute; top: 15px; right: 15px; font-size: 1.8rem; color: #aaa; cursor: pointer; transition: color 0.3s; line-height: 1; }
|
| 277 |
.close:hover { color: #333; }
|
| 278 |
-
body.dark-mode .close { color: #
|
| 279 |
body.dark-mode .close:hover { color: #ffffff; }
|
| 280 |
-
.modal-content h2 { margin-top: 0; margin-bottom: 20px; color: #
|
| 281 |
-
body.dark-mode .modal-content h2 { color: #
|
| 282 |
-
.cart-item { display: grid; grid-template-columns: auto 1fr auto auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #
|
| 283 |
-
body.dark-mode .cart-item { border-bottom-color: #
|
| 284 |
.cart-item:last-child { border-bottom: none; }
|
| 285 |
.cart-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; grid-column: 1; }
|
| 286 |
.cart-item-details { grid-column: 2; }
|
| 287 |
.cart-item-details strong { display: block; margin-bottom: 5px; font-size: 1rem; }
|
| 288 |
-
.cart-item-price { font-size: 0.9rem; color: #
|
| 289 |
-
body.dark-mode .cart-item-price { color: #
|
| 290 |
.cart-item-total { font-weight: bold; text-align: right; grid-column: 3; font-size: 1rem;}
|
| 291 |
.cart-item-remove { grid-column: 4; background:none; border:none; color:#f56565; cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; }
|
| 292 |
.cart-item-remove:hover { color: #c53030; }
|
| 293 |
-
.quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #
|
| 294 |
-
body.dark-mode .quantity-input, body.dark-mode .color-select { background-color: #
|
| 295 |
-
.cart-summary { margin-top: 20px; text-align: right; border-top: 1px solid #
|
| 296 |
-
body.dark-mode .cart-summary { border-top-color: #
|
| 297 |
.cart-summary strong { font-size: 1.2rem; }
|
| 298 |
.cart-actions { margin-top: 25px; display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
| 299 |
.cart-actions .product-button { width: auto; flex-grow: 1; }
|
| 300 |
-
.clear-cart { background-color: #a0aec0; }
|
| 301 |
.clear-cart:hover { background-color: #718096; box-shadow: 0 4px 15px rgba(113, 128, 150, 0.4); }
|
| 302 |
-
.formulate-order-button { background-color: #
|
| 303 |
-
.formulate-order-button:hover { background-color: #
|
| 304 |
-
.notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: #
|
| 305 |
.notification.show { opacity: 1;}
|
| 306 |
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #a0aec0; }
|
| 307 |
-
body.dark-mode .no-results-message { color: #
|
| 308 |
.top-product-indicator { position: absolute; top: 8px; right: 8px; background-color: rgba(255, 215, 0, 0.8); color: #333; padding: 2px 6px; font-size: 0.7rem; border-radius: 4px; font-weight: bold; z-index: 10; backdrop-filter: blur(2px); }
|
| 309 |
.product { position: relative; }
|
| 310 |
</style>
|
|
@@ -313,27 +281,23 @@ CATALOG_TEMPLATE = '''
|
|
| 313 |
<div class="container">
|
| 314 |
<div class="header">
|
| 315 |
<div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;">
|
| 316 |
-
<img src="https://
|
| 317 |
-
<h1>
|
| 318 |
</div>
|
| 319 |
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Переключить тему">
|
| 320 |
<i class="fas fa-moon"></i>
|
| 321 |
</button>
|
| 322 |
</div>
|
| 323 |
-
|
| 324 |
<div class="store-address">Наш адрес: {{ store_address }}</div>
|
| 325 |
-
|
| 326 |
<div class="filters-container">
|
| 327 |
<button class="category-filter active" data-category="all">Все категории</button>
|
| 328 |
{% for category in categories %}
|
| 329 |
<button class="category-filter" data-category="{{ category }}">{{ category }}</button>
|
| 330 |
{% endfor %}
|
| 331 |
</div>
|
| 332 |
-
|
| 333 |
<div class="search-container">
|
| 334 |
<input type="text" id="search-input" placeholder="Поиск по названию или описанию...">
|
| 335 |
</div>
|
| 336 |
-
|
| 337 |
<div class="products-grid" id="products-grid">
|
| 338 |
{% for product in products %}
|
| 339 |
<div class="product"
|
|
@@ -370,14 +334,12 @@ CATALOG_TEMPLATE = '''
|
|
| 370 |
{% endif %}
|
| 371 |
</div>
|
| 372 |
</div>
|
| 373 |
-
|
| 374 |
<div id="productModal" class="modal">
|
| 375 |
<div class="modal-content">
|
| 376 |
<span class="close" onclick="closeModal('productModal')" aria-label="Закрыть">×</span>
|
| 377 |
<div id="modalContent">Загрузка...</div>
|
| 378 |
</div>
|
| 379 |
</div>
|
| 380 |
-
|
| 381 |
<div id="quantityModal" class="modal">
|
| 382 |
<div class="modal-content">
|
| 383 |
<span class="close" onclick="closeModal('quantityModal')" aria-label="Закрыть">×</span>
|
|
@@ -389,7 +351,6 @@ CATALOG_TEMPLATE = '''
|
|
| 389 |
<button class="product-button add-to-cart" onclick="confirmAddToCart()"><i class="fas fa-check"></i> Добавить в корзину</button>
|
| 390 |
</div>
|
| 391 |
</div>
|
| 392 |
-
|
| 393 |
<div id="cartModal" class="modal">
|
| 394 |
<div class="modal-content">
|
| 395 |
<span class="close" onclick="closeModal('cartModal')" aria-label="Закрыть">×</span>
|
|
@@ -408,14 +369,11 @@ CATALOG_TEMPLATE = '''
|
|
| 408 |
</div>
|
| 409 |
</div>
|
| 410 |
</div>
|
| 411 |
-
|
| 412 |
<button id="cart-button" onclick="openCartModal()" aria-label="Открыть корзину">
|
| 413 |
<i class="fas fa-shopping-cart"></i>
|
| 414 |
<span id="cart-count">0</span>
|
| 415 |
</button>
|
| 416 |
-
|
| 417 |
<div id="notification-placeholder"></div>
|
| 418 |
-
|
| 419 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
|
| 420 |
<script>
|
| 421 |
const products = {{ products|tojson }};
|
|
@@ -423,7 +381,6 @@ CATALOG_TEMPLATE = '''
|
|
| 423 |
const currencyCode = '{{ currency_code }}';
|
| 424 |
let selectedProductIndex = null;
|
| 425 |
let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
|
| 426 |
-
|
| 427 |
function toggleTheme() {
|
| 428 |
document.body.classList.toggle('dark-mode');
|
| 429 |
const icon = document.querySelector('.theme-toggle i');
|
|
@@ -432,7 +389,6 @@ CATALOG_TEMPLATE = '''
|
|
| 432 |
icon.classList.toggle('fa-sun', isDarkMode);
|
| 433 |
localStorage.setItem('soolaTheme', isDarkMode ? 'dark' : 'light');
|
| 434 |
}
|
| 435 |
-
|
| 436 |
function applyInitialTheme() {
|
| 437 |
const savedTheme = localStorage.getItem('soolaTheme');
|
| 438 |
if (savedTheme === 'dark') {
|
|
@@ -441,7 +397,6 @@ CATALOG_TEMPLATE = '''
|
|
| 441 |
if (icon) icon.classList.replace('fa-moon', 'fa-sun');
|
| 442 |
}
|
| 443 |
}
|
| 444 |
-
|
| 445 |
function openModal(index) {
|
| 446 |
loadProductDetails(index);
|
| 447 |
const modal = document.getElementById('productModal');
|
|
@@ -450,7 +405,6 @@ CATALOG_TEMPLATE = '''
|
|
| 450 |
document.body.style.overflow = 'hidden';
|
| 451 |
}
|
| 452 |
}
|
| 453 |
-
|
| 454 |
function closeModal(modalId) {
|
| 455 |
const modal = document.getElementById(modalId);
|
| 456 |
if (modal) {
|
|
@@ -461,7 +415,6 @@ CATALOG_TEMPLATE = '''
|
|
| 461 |
document.body.style.overflow = 'auto';
|
| 462 |
}
|
| 463 |
}
|
| 464 |
-
|
| 465 |
function loadProductDetails(index) {
|
| 466 |
const modalContent = document.getElementById('modalContent');
|
| 467 |
if (!modalContent) return;
|
|
@@ -480,7 +433,6 @@ CATALOG_TEMPLATE = '''
|
|
| 480 |
modalContent.innerHTML = `<p style="color: red; text-align:center; padding: 40px;">Не удалось загрузить информацию о товаре. ${error.message}</p>`;
|
| 481 |
});
|
| 482 |
}
|
| 483 |
-
|
| 484 |
function initializeSwiper() {
|
| 485 |
const swiperContainer = document.querySelector('#productModal .swiper-container');
|
| 486 |
if (swiperContainer) {
|
|
@@ -496,7 +448,6 @@ CATALOG_TEMPLATE = '''
|
|
| 496 |
});
|
| 497 |
}
|
| 498 |
}
|
| 499 |
-
|
| 500 |
function openQuantityModal(index) {
|
| 501 |
selectedProductIndex = index;
|
| 502 |
const product = products[index];
|
|
@@ -505,13 +456,10 @@ CATALOG_TEMPLATE = '''
|
|
| 505 |
alert("Ошибка: товар не найден.");
|
| 506 |
return;
|
| 507 |
}
|
| 508 |
-
|
| 509 |
const colorSelect = document.getElementById('colorSelect');
|
| 510 |
const colorLabel = document.querySelector('label[for="colorSelect"]');
|
| 511 |
colorSelect.innerHTML = '';
|
| 512 |
-
|
| 513 |
const validColors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : [];
|
| 514 |
-
|
| 515 |
if (validColors.length > 0) {
|
| 516 |
validColors.forEach(color => {
|
| 517 |
const option = document.createElement('option');
|
|
@@ -525,7 +473,6 @@ CATALOG_TEMPLATE = '''
|
|
| 525 |
colorSelect.style.display = 'none';
|
| 526 |
if(colorLabel) colorLabel.style.display = 'none';
|
| 527 |
}
|
| 528 |
-
|
| 529 |
document.getElementById('quantityInput').value = 1;
|
| 530 |
const modal = document.getElementById('quantityModal');
|
| 531 |
if(modal) {
|
|
@@ -533,30 +480,24 @@ CATALOG_TEMPLATE = '''
|
|
| 533 |
document.body.style.overflow = 'hidden';
|
| 534 |
}
|
| 535 |
}
|
| 536 |
-
|
| 537 |
function confirmAddToCart() {
|
| 538 |
if (selectedProductIndex === null) return;
|
| 539 |
-
|
| 540 |
const quantityInput = document.getElementById('quantityInput');
|
| 541 |
const quantity = parseInt(quantityInput.value);
|
| 542 |
const colorSelect = document.getElementById('colorSelect');
|
| 543 |
const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A';
|
| 544 |
-
|
| 545 |
if (isNaN(quantity) || quantity <= 0) {
|
| 546 |
alert("Пожалуйста, укажите корректное количество (больше 0).");
|
| 547 |
quantityInput.focus();
|
| 548 |
return;
|
| 549 |
}
|
| 550 |
-
|
| 551 |
const product = products[selectedProductIndex];
|
| 552 |
if (!product) {
|
| 553 |
alert("Ошибка добавления: товар не найден.");
|
| 554 |
return;
|
| 555 |
}
|
| 556 |
-
|
| 557 |
-
const cartItemId = `${product.name}-${color}`; // Use name + color as ID
|
| 558 |
const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
|
| 559 |
-
|
| 560 |
if (existingItemIndex > -1) {
|
| 561 |
cart[existingItemIndex].quantity += quantity;
|
| 562 |
} else {
|
|
@@ -569,21 +510,17 @@ CATALOG_TEMPLATE = '''
|
|
| 569 |
color: color
|
| 570 |
});
|
| 571 |
}
|
| 572 |
-
|
| 573 |
localStorage.setItem('soolaCart', JSON.stringify(cart));
|
| 574 |
closeModal('quantityModal');
|
| 575 |
updateCartButton();
|
| 576 |
showNotification(`${product.name} добавлен в корзину!`);
|
| 577 |
}
|
| 578 |
-
|
| 579 |
function updateCartButton() {
|
| 580 |
const cartCountElement = document.getElementById('cart-count');
|
| 581 |
const cartButton = document.getElementById('cart-button');
|
| 582 |
if (!cartCountElement || !cartButton) return;
|
| 583 |
-
|
| 584 |
let totalItems = 0;
|
| 585 |
cart.forEach(item => { totalItems += item.quantity; });
|
| 586 |
-
|
| 587 |
if (totalItems > 0) {
|
| 588 |
cartCountElement.textContent = totalItems;
|
| 589 |
cartButton.style.display = 'flex';
|
|
@@ -592,14 +529,11 @@ CATALOG_TEMPLATE = '''
|
|
| 592 |
cartButton.style.display = 'none';
|
| 593 |
}
|
| 594 |
}
|
| 595 |
-
|
| 596 |
function openCartModal() {
|
| 597 |
const cartContent = document.getElementById('cartContent');
|
| 598 |
const cartTotalElement = document.getElementById('cartTotal');
|
| 599 |
if (!cartContent || !cartTotalElement) return;
|
| 600 |
-
|
| 601 |
let total = 0;
|
| 602 |
-
|
| 603 |
if (cart.length === 0) {
|
| 604 |
cartContent.innerHTML = '<p style="text-align: center; padding: 20px;">Ваша корзина пуста.</p>';
|
| 605 |
cartTotalElement.textContent = '0.00';
|
|
@@ -611,7 +545,6 @@ CATALOG_TEMPLATE = '''
|
|
| 611 |
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}`
|
| 612 |
: 'https://via.placeholder.com/60x60.png?text=N/A';
|
| 613 |
const colorText = item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
|
| 614 |
-
|
| 615 |
return `
|
| 616 |
<div class="cart-item">
|
| 617 |
<img src="${photoUrl}" alt="${item.name}">
|
|
@@ -632,14 +565,12 @@ CATALOG_TEMPLATE = '''
|
|
| 632 |
document.body.style.overflow = 'hidden';
|
| 633 |
}
|
| 634 |
}
|
| 635 |
-
|
| 636 |
function removeFromCart(itemId) {
|
| 637 |
cart = cart.filter(item => item.id !== itemId);
|
| 638 |
localStorage.setItem('soolaCart', JSON.stringify(cart));
|
| 639 |
openCartModal();
|
| 640 |
updateCartButton();
|
| 641 |
}
|
| 642 |
-
|
| 643 |
function clearCart() {
|
| 644 |
if (confirm("Вы уверены, что хотите очистить корзину?")) {
|
| 645 |
cart = [];
|
|
@@ -648,22 +579,17 @@ CATALOG_TEMPLATE = '''
|
|
| 648 |
updateCartButton();
|
| 649 |
}
|
| 650 |
}
|
| 651 |
-
|
| 652 |
function formulateOrder() {
|
| 653 |
if (cart.length === 0) {
|
| 654 |
alert("Корзина пуста! Добавьте товары перед формированием заказа.");
|
| 655 |
return;
|
| 656 |
}
|
| 657 |
-
|
| 658 |
const orderData = {
|
| 659 |
cart: cart
|
| 660 |
};
|
| 661 |
-
|
| 662 |
const formulateButton = document.querySelector('.formulate-order-button');
|
| 663 |
if (formulateButton) formulateButton.disabled = true;
|
| 664 |
-
|
| 665 |
showNotification("Формируем заказ...", 5000);
|
| 666 |
-
|
| 667 |
fetch('/create_order', {
|
| 668 |
method: 'POST',
|
| 669 |
headers: { 'Content-Type': 'application/json' },
|
|
@@ -692,26 +618,20 @@ CATALOG_TEMPLATE = '''
|
|
| 692 |
if (formulateButton) formulateButton.disabled = false;
|
| 693 |
});
|
| 694 |
}
|
| 695 |
-
|
| 696 |
-
|
| 697 |
function filterProducts() {
|
| 698 |
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
|
| 699 |
const activeCategoryButton = document.querySelector('.category-filter.active');
|
| 700 |
const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all';
|
| 701 |
const grid = document.getElementById('products-grid');
|
| 702 |
let visibleProducts = 0;
|
| 703 |
-
|
| 704 |
const existingNoResults = grid.querySelector('.no-results-message');
|
| 705 |
if (existingNoResults) existingNoResults.remove();
|
| 706 |
-
|
| 707 |
document.querySelectorAll('.products-grid .product').forEach(productElement => {
|
| 708 |
const name = productElement.getAttribute('data-name');
|
| 709 |
const description = productElement.getAttribute('data-description');
|
| 710 |
const category = productElement.getAttribute('data-category');
|
| 711 |
-
|
| 712 |
const matchesSearch = !searchTerm || name.includes(searchTerm) || description.includes(searchTerm);
|
| 713 |
const matchesCategory = activeCategory === 'all' || category === activeCategory;
|
| 714 |
-
|
| 715 |
if (matchesSearch && matchesCategory) {
|
| 716 |
productElement.style.display = 'flex';
|
| 717 |
visibleProducts++;
|
|
@@ -719,7 +639,6 @@ CATALOG_TEMPLATE = '''
|
|
| 719 |
productElement.style.display = 'none';
|
| 720 |
}
|
| 721 |
});
|
| 722 |
-
|
| 723 |
if (visibleProducts === 0 && products.length > 0) {
|
| 724 |
const p = document.createElement('p');
|
| 725 |
p.className = 'no-results-message';
|
|
@@ -732,13 +651,10 @@ CATALOG_TEMPLATE = '''
|
|
| 732 |
grid.appendChild(p);
|
| 733 |
}
|
| 734 |
}
|
| 735 |
-
|
| 736 |
function setupFilters() {
|
| 737 |
const searchInput = document.getElementById('search-input');
|
| 738 |
const categoryFilters = document.querySelectorAll('.category-filter');
|
| 739 |
-
|
| 740 |
if(searchInput) searchInput.addEventListener('input', filterProducts);
|
| 741 |
-
|
| 742 |
categoryFilters.forEach(filter => {
|
| 743 |
filter.addEventListener('click', function() {
|
| 744 |
categoryFilters.forEach(f => f.classList.remove('active'));
|
|
@@ -748,7 +664,6 @@ CATALOG_TEMPLATE = '''
|
|
| 748 |
});
|
| 749 |
filterProducts();
|
| 750 |
}
|
| 751 |
-
|
| 752 |
function showNotification(message, duration = 3000) {
|
| 753 |
const placeholder = document.getElementById('notification-placeholder');
|
| 754 |
if (!placeholder) {
|
|
@@ -762,34 +677,26 @@ CATALOG_TEMPLATE = '''
|
|
| 762 |
document.body.appendChild(newPlaceholder);
|
| 763 |
placeholder = newPlaceholder;
|
| 764 |
}
|
| 765 |
-
|
| 766 |
-
|
| 767 |
const notification = document.createElement('div');
|
| 768 |
notification.className = 'notification';
|
| 769 |
notification.textContent = message;
|
| 770 |
placeholder.appendChild(notification);
|
| 771 |
-
|
| 772 |
void notification.offsetWidth;
|
| 773 |
-
|
| 774 |
notification.classList.add('show');
|
| 775 |
-
|
| 776 |
setTimeout(() => {
|
| 777 |
notification.classList.remove('show');
|
| 778 |
notification.addEventListener('transitionend', () => notification.remove());
|
| 779 |
}, duration);
|
| 780 |
}
|
| 781 |
-
|
| 782 |
document.addEventListener('DOMContentLoaded', () => {
|
| 783 |
applyInitialTheme();
|
| 784 |
updateCartButton();
|
| 785 |
setupFilters();
|
| 786 |
-
|
| 787 |
window.addEventListener('click', function(event) {
|
| 788 |
if (event.target.classList.contains('modal')) {
|
| 789 |
closeModal(event.target.id);
|
| 790 |
}
|
| 791 |
});
|
| 792 |
-
|
| 793 |
window.addEventListener('keydown', function(event) {
|
| 794 |
if (event.key === 'Escape') {
|
| 795 |
document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
|
|
@@ -798,15 +705,13 @@ CATALOG_TEMPLATE = '''
|
|
| 798 |
}
|
| 799 |
});
|
| 800 |
});
|
| 801 |
-
|
| 802 |
</script>
|
| 803 |
</body>
|
| 804 |
</html>
|
| 805 |
'''
|
| 806 |
-
|
| 807 |
PRODUCT_DETAIL_TEMPLATE = '''
|
| 808 |
<div style="padding: 10px;">
|
| 809 |
-
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #
|
| 810 |
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
|
| 811 |
<div class="swiper-wrapper">
|
| 812 |
{% if product.get('photos') and product['photos']|length > 0 %}
|
|
@@ -827,14 +732,13 @@ PRODUCT_DETAIL_TEMPLATE = '''
|
|
| 827 |
</div>
|
| 828 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 829 |
<div class="swiper-pagination" style="position: relative; bottom: 5px;"></div>
|
| 830 |
-
<div class="swiper-button-next" style="color: #
|
| 831 |
-
<div class="swiper-button-prev" style="color: #
|
| 832 |
{% endif %}
|
| 833 |
</div>
|
| 834 |
-
|
| 835 |
<div style="margin-top: 20px; font-size: 1rem; line-height: 1.7;">
|
| 836 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 837 |
-
<p style="font-size: 1.2rem; font-weight: bold; color: #
|
| 838 |
<p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\\n', '<br>')|safe }}</p>
|
| 839 |
{% set colors = product.get('colors', []) %}
|
| 840 |
{% if colors and colors|select('ne', '')|list|length > 0 %}
|
|
@@ -843,40 +747,39 @@ PRODUCT_DETAIL_TEMPLATE = '''
|
|
| 843 |
</div>
|
| 844 |
</div>
|
| 845 |
'''
|
| 846 |
-
|
| 847 |
ORDER_TEMPLATE = '''
|
| 848 |
<!DOCTYPE html>
|
| 849 |
<html lang="ru">
|
| 850 |
<head>
|
| 851 |
<meta charset="UTF-8">
|
| 852 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 853 |
-
<title>Заказ №{{ order.id }} -
|
| 854 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 855 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 856 |
<style>
|
| 857 |
-
body { font-family: 'Poppins', sans-serif; background: #
|
| 858 |
-
.container { max-width: 800px; margin: 20px auto; padding: 30px; background: #fff; border-radius: 15px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); border: 1px solid #
|
| 859 |
-
h1 { text-align: center; color: #
|
| 860 |
-
h2 { color: #
|
| 861 |
.order-meta { font-size: 0.9rem; color: #a0aec0; margin-bottom: 20px; text-align: center; }
|
| 862 |
-
.order-item { display: grid; grid-template-columns: 60px 1fr auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #
|
| 863 |
.order-item:last-child { border-bottom: none; }
|
| 864 |
-
.order-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; border: 1px solid #
|
| 865 |
-
.item-details strong { display: block; margin-bottom: 5px; font-size: 1.05rem; color: #
|
| 866 |
-
.item-details span { font-size: 0.9rem; color: #
|
| 867 |
-
.item-total { font-weight: bold; text-align: right; font-size: 1rem; color: #
|
| 868 |
-
.order-summary { margin-top: 30px; padding-top: 20px; border-top: 2px solid #
|
| 869 |
.order-summary p { margin-bottom: 10px; font-size: 1.1rem; }
|
| 870 |
-
.order-summary strong { font-size: 1.3rem; color: #
|
| 871 |
-
.customer-info { margin-top: 30px; background-color: #
|
| 872 |
.customer-info p { margin-bottom: 8px; font-size: 0.95rem; }
|
| 873 |
-
.customer-info strong { color: #
|
| 874 |
.actions { margin-top: 30px; text-align: center; }
|
| 875 |
-
.button { padding: 12px 25px; border: none; border-radius: 8px; background-color: #
|
| 876 |
-
.button:hover { background-color: #
|
| 877 |
.button:active { transform: scale(0.98); }
|
| 878 |
.button i { font-size: 1.2rem; }
|
| 879 |
-
.catalog-link { display: block; text-align: center; margin-top: 25px; color: #
|
| 880 |
.catalog-link:hover { text-decoration: underline; }
|
| 881 |
.not-found { text-align: center; color: #c53030; font-size: 1.2rem; padding: 40px 0;}
|
| 882 |
</style>
|
|
@@ -886,7 +789,6 @@ ORDER_TEMPLATE = '''
|
|
| 886 |
{% if order %}
|
| 887 |
<h1><i class="fas fa-receipt"></i> Ваш Заказ №{{ order.id }}</h1>
|
| 888 |
<p class="order-meta">Дата создания: {{ order.created_at }}</p>
|
| 889 |
-
|
| 890 |
<h2><i class="fas fa-shopping-bag"></i> Товары в заказе</h2>
|
| 891 |
<div id="orderItems">
|
| 892 |
{% for item in order.cart %}
|
|
@@ -902,40 +804,32 @@ ORDER_TEMPLATE = '''
|
|
| 902 |
</div>
|
| 903 |
{% endfor %}
|
| 904 |
</div>
|
| 905 |
-
|
| 906 |
<div class="order-summary">
|
| 907 |
<p>Общая сумма товаров: <strong>{{ "%.2f"|format(order.total_price) }} {{ currency_code }}</strong></p>
|
| 908 |
<p><strong>ИТОГО К ОПЛАТЕ: {{ "%.2f"|format(order.total_price) }} {{ currency_code }}</strong></p>
|
| 909 |
</div>
|
| 910 |
-
|
| 911 |
<div class="customer-info">
|
| 912 |
<h2><i class="fas fa-info-circle"></i> Статус заказа</h2>
|
| 913 |
<p>Этот заказ был оформлен без входа в систему.</p>
|
| 914 |
<p>Пожалуйста, свяжитесь с нами по WhatsApp для подтверждения и уточнения деталей.</p>
|
| 915 |
</div>
|
| 916 |
-
|
| 917 |
<div class="actions">
|
| 918 |
<button class="button" onclick="sendOrderViaWhatsApp()"><i class="fab fa-whatsapp"></i> Отправить заказ</button>
|
| 919 |
</div>
|
| 920 |
-
|
| 921 |
<a href="{{ url_for('catalog') }}" class="catalog-link">← Вернуться в каталог</a>
|
| 922 |
-
|
| 923 |
<script>
|
| 924 |
function sendOrderViaWhatsApp() {
|
| 925 |
const orderId = '{{ order.id }}';
|
| 926 |
const orderUrl = `{{ request.url }}`;
|
| 927 |
-
const whatsappNumber = "
|
| 928 |
-
|
| 929 |
-
let message = `Здравствуйте! Хочу подтвердить свой заказ на Aikas_optom:%0A%0A`; /* UPDATED NAME */
|
| 930 |
message += `*Номер заказа:* ${orderId}%0A`;
|
| 931 |
message += `*Ссылка на заказ:* ${encodeURIComponent(orderUrl)}%0A%0A`;
|
| 932 |
message += `Пожалуйста, свяжитесь со мной для уточнения деталей оплаты и доставки.`;
|
| 933 |
-
|
| 934 |
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${message}`;
|
| 935 |
window.open(whatsappUrl, '_blank');
|
| 936 |
}
|
| 937 |
</script>
|
| 938 |
-
|
| 939 |
{% else %}
|
| 940 |
<h1 style="color: #c53030;"><i class="fas fa-exclamation-triangle"></i> Ошибка</h1>
|
| 941 |
<p class="not-found">Заказ с таким ID не найден.</p>
|
|
@@ -945,90 +839,87 @@ ORDER_TEMPLATE = '''
|
|
| 945 |
</body>
|
| 946 |
</html>
|
| 947 |
'''
|
| 948 |
-
|
| 949 |
ADMIN_TEMPLATE = '''
|
| 950 |
<!DOCTYPE html>
|
| 951 |
<html lang="ru">
|
| 952 |
<head>
|
| 953 |
<meta charset="UTF-8">
|
| 954 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 955 |
-
<title>Админ-панель -
|
| 956 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 957 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 958 |
<style>
|
| 959 |
-
body { font-family: 'Poppins', sans-serif; background-color: #
|
| 960 |
.container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
|
| 961 |
-
.header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #
|
| 962 |
-
h1, h2, h3 { font-weight: 600; color: #
|
| 963 |
h1 { font-size: 1.8rem; }
|
| 964 |
h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
|
| 965 |
-
h3 { font-size: 1.2rem; color: #
|
| 966 |
-
.section { margin-bottom: 30px; padding: 20px; background-color: #
|
| 967 |
form { margin-bottom: 20px; }
|
| 968 |
-
label { font-weight: 500; margin-top: 10px; display: block; color: #
|
| 969 |
-
input[type="text"], input[type="number"], input[type="password"], input[type="tel"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #
|
| 970 |
-
input:focus, textarea:focus, select:focus { border-color: #
|
| 971 |
textarea { min-height: 80px; resize: vertical; }
|
| 972 |
-
input[type="file"] { padding: 8px; background-color: #
|
| 973 |
-
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #
|
| 974 |
input[type="checkbox"] { margin-right: 5px; vertical-align: middle; }
|
| 975 |
label.inline-label { display: inline-block; margin-top: 10px; font-weight: normal; }
|
| 976 |
-
button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #
|
| 977 |
-
button:hover, .button:hover { background-color: #
|
| 978 |
button:active, .button:active { transform: scale(0.98); }
|
| 979 |
button[type="submit"] { min-width: 120px; justify-content: center; }
|
| 980 |
.delete-button { background-color: #f56565; }
|
| 981 |
.delete-button:hover { background-color: #e53e3e; }
|
| 982 |
-
.add-button { background-color: #
|
| 983 |
-
.add-button:hover { background-color: #
|
| 984 |
.item-list { display: grid; gap: 20px; }
|
| 985 |
-
.item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #
|
| 986 |
-
.item p { margin: 5px 0; font-size: 0.9rem; color: #
|
| 987 |
-
.item strong { color: #
|
| 988 |
.item .description { font-size: 0.85rem; color: #a0aec0; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
|
| 989 |
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
| 990 |
-
.item-actions button:not(.delete-button) { background-color: #
|
| 991 |
-
.item-actions button:not(.delete-button):hover { background-color: #
|
| 992 |
-
.edit-form-container { margin-top: 15px; padding: 20px; background: #
|
| 993 |
-
details { background-color: #
|
| 994 |
-
details > summary { cursor: pointer; font-weight: 600; color: #
|
| 995 |
-
details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: #
|
| 996 |
details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
|
| 997 |
-
details[open] > summary { border-bottom: 1px solid #
|
| 998 |
details .form-content { padding: 20px; }
|
| 999 |
.color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
| 1000 |
.color-input-group input { flex-grow: 1; margin: 0; }
|
| 1001 |
.remove-color-btn { background-color: #f56565; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; }
|
| 1002 |
.remove-color-btn:hover { background-color: #e53e3e; }
|
| 1003 |
-
.add-color-btn { background-color: #
|
| 1004 |
-
.add-color-btn:hover { background-color: #
|
| 1005 |
-
.photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #
|
| 1006 |
.sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
|
| 1007 |
-
.download-hf-button { background-color: #a0aec0; }
|
| 1008 |
.download-hf-button:hover { background-color: #718096; }
|
| 1009 |
.flex-container { display: flex; flex-wrap: wrap; gap: 20px; }
|
| 1010 |
.flex-item { flex: 1; min-width: 350px; }
|
| 1011 |
.message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;}
|
| 1012 |
-
.message.success { background-color: #f0fff4; color: #155724; border: 1px solid #c3e6cb;}
|
| 1013 |
-
.message.error { background-color: #fff5f5; color: #721c24; border: 1px solid #f5c6cb;}
|
| 1014 |
-
.message.warning { background-color: #fffaf0; color: #856404; border: 1px solid #ffeeba; }
|
| 1015 |
.status-indicator { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 500; margin-left: 10px; vertical-align: middle; }
|
| 1016 |
-
.status-indicator.in-stock { background-color: #c6f6d5; color: #2f855a; }
|
| 1017 |
-
.status-indicator.out-of-stock { background-color: #fed7d7; color: #c53030; }
|
| 1018 |
-
.status-indicator.top-product { background-color: #feebc8; color: #9c4221; margin-left: 5px;}
|
| 1019 |
</style>
|
| 1020 |
</head>
|
| 1021 |
<body>
|
| 1022 |
<div class="container">
|
| 1023 |
<div class="header">
|
| 1024 |
<div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;">
|
| 1025 |
-
<img src="https://
|
| 1026 |
-
<h1><i class="fas fa-tools"></i> Админ-панель
|
| 1027 |
</div>
|
| 1028 |
-
<a href="{{ url_for('catalog') }}" class="button" style="background-color: #
|
| 1029 |
</div>
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 1033 |
{% if messages %}
|
| 1034 |
{% for category, message in messages %}
|
|
@@ -1036,7 +927,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1036 |
{% endfor %}
|
| 1037 |
{% endif %}
|
| 1038 |
{% endwith %}
|
| 1039 |
-
|
| 1040 |
<div class="section">
|
| 1041 |
<h2><i class="fas fa-sync-alt"></i> Синхронизация с Датацентром</h2>
|
| 1042 |
<div class="sync-buttons">
|
|
@@ -1047,9 +937,8 @@ ADMIN_TEMPLATE = '''
|
|
| 1047 |
<button type="submit" class="button download-hf-button" title="Скачать файлы (перезапишет локальные)"><i class="fas fa-download"></i> Скачать БД</button>
|
| 1048 |
</form>
|
| 1049 |
</div>
|
| 1050 |
-
<p style="font-size: 0.85rem; color: #
|
| 1051 |
</div>
|
| 1052 |
-
|
| 1053 |
<div class="flex-container">
|
| 1054 |
<div class="flex-item">
|
| 1055 |
<div class="section">
|
|
@@ -1065,7 +954,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1065 |
</form>
|
| 1066 |
</div>
|
| 1067 |
</details>
|
| 1068 |
-
|
| 1069 |
<h3>Существующие категории:</h3>
|
| 1070 |
{% if categories %}
|
| 1071 |
<div class="item-list">
|
|
@@ -1085,7 +973,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1085 |
{% endif %}
|
| 1086 |
</div>
|
| 1087 |
</div>
|
| 1088 |
-
|
| 1089 |
<div class="flex-item">
|
| 1090 |
<div class="section">
|
| 1091 |
<h2><i class="fas fa-info-circle"></i> Информация</h2>
|
|
@@ -1094,7 +981,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1094 |
</div>
|
| 1095 |
</div>
|
| 1096 |
</div>
|
| 1097 |
-
|
| 1098 |
<div class="section">
|
| 1099 |
<h2><i class="fas fa-box-open"></i> Управление товарами</h2>
|
| 1100 |
<details>
|
|
@@ -1139,7 +1025,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1139 |
</form>
|
| 1140 |
</div>
|
| 1141 |
</details>
|
| 1142 |
-
|
| 1143 |
<h3>Список товаров:</h3>
|
| 1144 |
{% if products %}
|
| 1145 |
<div class="item-list">
|
|
@@ -1156,7 +1041,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1156 |
{% endif %}
|
| 1157 |
</div>
|
| 1158 |
<div style="flex-grow: 1;">
|
| 1159 |
-
<h3 style="margin-top: 0; margin-bottom: 5px; color: #
|
| 1160 |
{{ product['name'] }}
|
| 1161 |
{% if product.get('in_stock', True) %}
|
| 1162 |
<span class="status-indicator in-stock">В наличии</span>
|
|
@@ -1173,11 +1058,10 @@ ADMIN_TEMPLATE = '''
|
|
| 1173 |
{% set colors = product.get('colors', []) %}
|
| 1174 |
<p><strong>Цвета/Вар-ты:</strong> {{ colors|select('ne', '')|join(', ') if colors|select('ne', '')|list|length > 0 else 'Нет' }}</p>
|
| 1175 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 1176 |
-
<p style="font-size: 0.8rem; color: #
|
| 1177 |
{% endif %}
|
| 1178 |
</div>
|
| 1179 |
</div>
|
| 1180 |
-
|
| 1181 |
<div class="item-actions">
|
| 1182 |
<button type="button" class="button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
|
| 1183 |
<form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
|
|
@@ -1186,7 +1070,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1186 |
<button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
|
| 1187 |
</form>
|
| 1188 |
</div>
|
| 1189 |
-
|
| 1190 |
<div id="edit-form-{{ loop.index0 }}" class="edit-form-container">
|
| 1191 |
<h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
|
| 1192 |
<form method="POST" enctype="multipart/form-data">
|
|
@@ -1229,7 +1112,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1229 |
{% endfor %}
|
| 1230 |
{% else %}
|
| 1231 |
<div class="color-input-group">
|
| 1232 |
-
<input type="text" name="colors" placeholder="Например:
|
| 1233 |
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1234 |
</div>
|
| 1235 |
{% endif %}
|
|
@@ -1255,9 +1138,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1255 |
<p>Товаров пока нет.</p>
|
| 1256 |
{% endif %}
|
| 1257 |
</div>
|
| 1258 |
-
|
| 1259 |
</div>
|
| 1260 |
-
|
| 1261 |
<script>
|
| 1262 |
function toggleEditForm(formId) {
|
| 1263 |
const formContainer = document.getElementById(formId);
|
|
@@ -1265,7 +1146,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1265 |
formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none';
|
| 1266 |
}
|
| 1267 |
}
|
| 1268 |
-
|
| 1269 |
function addColorInput(containerId) {
|
| 1270 |
const container = document.getElementById(containerId);
|
| 1271 |
if (container) {
|
|
@@ -1282,13 +1162,11 @@ ADMIN_TEMPLATE = '''
|
|
| 1282 |
}
|
| 1283 |
}
|
| 1284 |
}
|
| 1285 |
-
|
| 1286 |
function removeColorInput(button) {
|
| 1287 |
const group = button.closest('.color-input-group');
|
| 1288 |
if (group) {
|
| 1289 |
const container = group.parentNode;
|
| 1290 |
group.remove();
|
| 1291 |
-
// Add back a placeholder if the last one was removed
|
| 1292 |
if (container && container.children.length === 0) {
|
| 1293 |
const placeholderGroup = document.createElement('div');
|
| 1294 |
placeholderGroup.className = 'color-input-group';
|
|
@@ -1306,18 +1184,13 @@ ADMIN_TEMPLATE = '''
|
|
| 1306 |
</body>
|
| 1307 |
</html>
|
| 1308 |
'''
|
| 1309 |
-
|
| 1310 |
-
# --- Flask Routes ---
|
| 1311 |
-
|
| 1312 |
@app.route('/')
|
| 1313 |
def catalog():
|
| 1314 |
data = load_data()
|
| 1315 |
all_products = data.get('products', [])
|
| 1316 |
categories = sorted(data.get('categories', []))
|
| 1317 |
-
|
| 1318 |
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 1319 |
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 1320 |
-
|
| 1321 |
return render_template_string(
|
| 1322 |
CATALOG_TEMPLATE,
|
| 1323 |
products=products_sorted,
|
|
@@ -1326,37 +1199,30 @@ def catalog():
|
|
| 1326 |
store_address=STORE_ADDRESS,
|
| 1327 |
currency_code=CURRENCY_CODE
|
| 1328 |
)
|
| 1329 |
-
|
| 1330 |
@app.route('/product/<int:index>')
|
| 1331 |
def product_detail(index):
|
| 1332 |
data = load_data()
|
| 1333 |
all_products = data.get('products', [])
|
| 1334 |
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 1335 |
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 1336 |
-
|
| 1337 |
try:
|
| 1338 |
product = products_sorted[index]
|
| 1339 |
except IndexError:
|
| 1340 |
logging.warning(f"Attempted access to non-existent or out-of-stock product with index {index}")
|
| 1341 |
return "Товар не найден или отсутствует в наличии.", 404
|
| 1342 |
-
|
| 1343 |
return render_template_string(
|
| 1344 |
PRODUCT_DETAIL_TEMPLATE,
|
| 1345 |
product=product,
|
| 1346 |
repo_id=REPO_ID,
|
| 1347 |
currency_code=CURRENCY_CODE
|
| 1348 |
)
|
| 1349 |
-
|
| 1350 |
@app.route('/create_order', methods=['POST'])
|
| 1351 |
def create_order():
|
| 1352 |
order_data = request.get_json()
|
| 1353 |
-
|
| 1354 |
if not order_data or 'cart' not in order_data or not order_data['cart']:
|
| 1355 |
logging.warning("Create order request missing cart data or cart is empty.")
|
| 1356 |
return jsonify({"error": "Корзина пуста или не передана."}), 400
|
| 1357 |
-
|
| 1358 |
cart_items = order_data['cart']
|
| 1359 |
-
|
| 1360 |
total_price = 0
|
| 1361 |
processed_cart = []
|
| 1362 |
for item in cart_items:
|
|
@@ -1380,48 +1246,40 @@ def create_order():
|
|
| 1380 |
except (ValueError, TypeError) as e:
|
| 1381 |
logging.error(f"Invalid price/quantity in cart item: {item}. Error: {e}")
|
| 1382 |
return jsonify({"error": "Неверная цена или количество в товаре."}), 400
|
| 1383 |
-
|
| 1384 |
order_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
| 1385 |
order_timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 1386 |
-
|
| 1387 |
new_order = {
|
| 1388 |
"id": order_id,
|
| 1389 |
"created_at": order_timestamp,
|
| 1390 |
"cart": processed_cart,
|
| 1391 |
"total_price": round(total_price, 2),
|
| 1392 |
-
"user_info": None,
|
| 1393 |
"status": "new"
|
| 1394 |
}
|
| 1395 |
-
|
| 1396 |
try:
|
| 1397 |
data = load_data()
|
| 1398 |
if 'orders' not in data or not isinstance(data.get('orders'), dict):
|
| 1399 |
data['orders'] = {}
|
| 1400 |
-
|
| 1401 |
data['orders'][order_id] = new_order
|
| 1402 |
save_data(data)
|
| 1403 |
logging.info(f"Order {order_id} created successfully (anonymously).")
|
| 1404 |
return jsonify({"order_id": order_id}), 201
|
| 1405 |
-
|
| 1406 |
except Exception as e:
|
| 1407 |
logging.error(f"Failed to save order {order_id}: {e}", exc_info=True)
|
| 1408 |
return jsonify({"error": "Ошибка сервера при сохранении заказа."}), 500
|
| 1409 |
-
|
| 1410 |
@app.route('/order/<order_id>')
|
| 1411 |
def view_order(order_id):
|
| 1412 |
data = load_data()
|
| 1413 |
order = data.get('orders', {}).get(order_id)
|
| 1414 |
-
|
| 1415 |
if order:
|
| 1416 |
logging.info(f"Displaying order {order_id}")
|
| 1417 |
else:
|
| 1418 |
logging.warning(f"Order {order_id} not found.")
|
| 1419 |
-
|
| 1420 |
return render_template_string(ORDER_TEMPLATE,
|
| 1421 |
order=order,
|
| 1422 |
repo_id=REPO_ID,
|
| 1423 |
-
currency_code=CURRENCY_CODE
|
| 1424 |
-
|
| 1425 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1426 |
def admin():
|
| 1427 |
data = load_data()
|
|
@@ -1429,11 +1287,9 @@ def admin():
|
|
| 1429 |
categories = data.get('categories', [])
|
| 1430 |
if 'orders' not in data or not isinstance(data.get('orders'), dict):
|
| 1431 |
data['orders'] = {}
|
| 1432 |
-
|
| 1433 |
if request.method == 'POST':
|
| 1434 |
action = request.form.get('action')
|
| 1435 |
logging.info(f"Admin action received: {action}")
|
| 1436 |
-
|
| 1437 |
try:
|
| 1438 |
if action == 'add_category':
|
| 1439 |
category_name = request.form.get('category_name', '').strip()
|
|
@@ -1449,7 +1305,6 @@ def admin():
|
|
| 1449 |
else:
|
| 1450 |
logging.warning(f"Category '{category_name}' already exists.")
|
| 1451 |
flash(f"Категория '{category_name}' уже существует.", 'error')
|
| 1452 |
-
|
| 1453 |
elif action == 'delete_category':
|
| 1454 |
category_to_delete = request.form.get('category_name')
|
| 1455 |
if category_to_delete and category_to_delete in categories:
|
|
@@ -1467,7 +1322,6 @@ def admin():
|
|
| 1467 |
else:
|
| 1468 |
logging.warning(f"Attempted to delete non-existent or empty category: {category_to_delete}")
|
| 1469 |
flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error')
|
| 1470 |
-
|
| 1471 |
elif action == 'add_product':
|
| 1472 |
name = request.form.get('name', '').strip()
|
| 1473 |
price_str = request.form.get('price', '').replace(',', '.')
|
|
@@ -1477,18 +1331,15 @@ def admin():
|
|
| 1477 |
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1478 |
in_stock = 'in_stock' in request.form
|
| 1479 |
is_top = 'is_top' in request.form
|
| 1480 |
-
|
| 1481 |
if not name or not price_str:
|
| 1482 |
flash("Название и цена товара обязательны.", 'error')
|
| 1483 |
return redirect(url_for('admin'))
|
| 1484 |
-
|
| 1485 |
try:
|
| 1486 |
price = round(float(price_str), 2)
|
| 1487 |
if price < 0: price = 0
|
| 1488 |
except ValueError:
|
| 1489 |
flash("Неверный формат цены.", 'error')
|
| 1490 |
return redirect(url_for('admin'))
|
| 1491 |
-
|
| 1492 |
photos_list = []
|
| 1493 |
if photos_files and HF_TOKEN_WRITE:
|
| 1494 |
uploads_dir = 'uploads_temp'
|
|
@@ -1508,7 +1359,6 @@ def admin():
|
|
| 1508 |
logging.warning(f"Skipping non-image file upload: {photo.filename}")
|
| 1509 |
flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning")
|
| 1510 |
continue
|
| 1511 |
-
|
| 1512 |
safe_name = secure_filename(name.replace(' ', '_'))[:50]
|
| 1513 |
photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1514 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
|
@@ -1541,8 +1391,6 @@ def admin():
|
|
| 1541 |
logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}")
|
| 1542 |
elif not HF_TOKEN_WRITE and photos_files and any(f.filename for f in photos_files):
|
| 1543 |
flash("HF_TOKEN (write) не настроен. Фотографии не были загружены.", "warning")
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
new_product = {
|
| 1547 |
'name': name, 'price': price, 'description': description,
|
| 1548 |
'category': category if category in categories else 'Без категории',
|
|
@@ -1554,25 +1402,21 @@ def admin():
|
|
| 1554 |
save_data(data)
|
| 1555 |
logging.info(f"Product '{name}' added.")
|
| 1556 |
flash(f"Товар '{name}' успешно добавлен.", 'success')
|
| 1557 |
-
|
| 1558 |
elif action == 'edit_product':
|
| 1559 |
index_str = request.form.get('index')
|
| 1560 |
if index_str is None:
|
| 1561 |
flash("Ошибка редактирования: индекс товара не передан.", 'error')
|
| 1562 |
return redirect(url_for('admin'))
|
| 1563 |
-
|
| 1564 |
try:
|
| 1565 |
index = int(index_str)
|
| 1566 |
if not (0 <= index < len(products)):
|
| 1567 |
raise IndexError("Product index out of range")
|
| 1568 |
product_to_edit = products[index]
|
| 1569 |
original_name = product_to_edit.get('name', 'N/A')
|
| 1570 |
-
|
| 1571 |
except (ValueError, IndexError):
|
| 1572 |
flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
|
| 1573 |
logging.error(f"Invalid index '{index_str}' for editing. Product list length: {len(products)}")
|
| 1574 |
return redirect(url_for('admin'))
|
| 1575 |
-
|
| 1576 |
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
|
| 1577 |
price_str = request.form.get('price', str(product_to_edit['price'])).replace(',', '.')
|
| 1578 |
product_to_edit['description'] = request.form.get('description', product_to_edit.get('description', '')).strip()
|
|
@@ -1581,7 +1425,6 @@ def admin():
|
|
| 1581 |
product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1582 |
product_to_edit['in_stock'] = 'in_stock' in request.form
|
| 1583 |
product_to_edit['is_top'] = 'is_top' in request.form
|
| 1584 |
-
|
| 1585 |
try:
|
| 1586 |
price = round(float(price_str), 2)
|
| 1587 |
if price < 0: price = 0
|
|
@@ -1589,7 +1432,6 @@ def admin():
|
|
| 1589 |
except ValueError:
|
| 1590 |
logging.warning(f"Invalid price format '{price_str}' during edit of product {original_name}. Price not changed.")
|
| 1591 |
flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
|
| 1592 |
-
|
| 1593 |
photos_files = request.files.getlist('photos')
|
| 1594 |
if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
|
| 1595 |
uploads_dir = 'uploads_temp'
|
|
@@ -1611,7 +1453,6 @@ def admin():
|
|
| 1611 |
logging.warning(f"Skipping non-image file upload during edit: {photo.filename}")
|
| 1612 |
flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning")
|
| 1613 |
continue
|
| 1614 |
-
|
| 1615 |
safe_name = secure_filename(product_to_edit['name'].replace(' ', '_'))[:50]
|
| 1616 |
photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1617 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
|
@@ -1635,7 +1476,6 @@ def admin():
|
|
| 1635 |
os.rmdir(uploads_dir)
|
| 1636 |
except OSError as e:
|
| 1637 |
logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}")
|
| 1638 |
-
|
| 1639 |
if new_photos_list:
|
| 1640 |
logging.info(f"New photo list for product {product_to_edit['name']} generated.")
|
| 1641 |
old_photos = product_to_edit.get('photos', [])
|
|
@@ -1659,13 +1499,11 @@ def admin():
|
|
| 1659 |
flash("Не удалось загрузить новые фотографии (возможно, неверный формат).", "error")
|
| 1660 |
elif not HF_TOKEN_WRITE and photos_files and any(f.filename for f in photos_files):
|
| 1661 |
flash("HF_TOKEN (write) не настроен. Фотографии не были обновлены.", "warning")
|
| 1662 |
-
|
| 1663 |
products[index] = product_to_edit
|
| 1664 |
data['products'] = products
|
| 1665 |
save_data(data)
|
| 1666 |
logging.info(f"Product '{original_name}' (index {index}) updated to '{product_to_edit['name']}'.")
|
| 1667 |
flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
|
| 1668 |
-
|
| 1669 |
elif action == 'delete_product':
|
| 1670 |
index_str = request.form.get('index')
|
| 1671 |
if index_str is None:
|
|
@@ -1676,7 +1514,6 @@ def admin():
|
|
| 1676 |
if not (0 <= index < len(products)): raise IndexError("Product index out of range")
|
| 1677 |
deleted_product = products.pop(index)
|
| 1678 |
product_name = deleted_product.get('name', 'N/A')
|
| 1679 |
-
|
| 1680 |
photos_to_delete = deleted_product.get('photos', [])
|
| 1681 |
if photos_to_delete and HF_TOKEN_WRITE:
|
| 1682 |
logging.info(f"Attempting to delete photos for product '{product_name}' from HF: {photos_to_delete}")
|
|
@@ -1696,8 +1533,6 @@ def admin():
|
|
| 1696 |
elif photos_to_delete and not HF_TOKEN_WRITE:
|
| 1697 |
logging.warning(f"HF_TOKEN (write) not set. Cannot delete photos {photos_to_delete} for deleted product '{product_name}'.")
|
| 1698 |
flash(f"Товар '{product_name}' удален локально, но фото не удалены с сервера (токен не задан).", "warning")
|
| 1699 |
-
|
| 1700 |
-
|
| 1701 |
data['products'] = products
|
| 1702 |
save_data(data)
|
| 1703 |
logging.info(f"Product '{product_name}' (original index {index}) deleted.")
|
|
@@ -1705,23 +1540,17 @@ def admin():
|
|
| 1705 |
except (ValueError, IndexError):
|
| 1706 |
flash(f"Ошибка удаления: неверный индекс товара '{index_str}'.", 'error')
|
| 1707 |
logging.error(f"Invalid index '{index_str}' for deletion. Product list length: {len(products)}")
|
| 1708 |
-
|
| 1709 |
else:
|
| 1710 |
logging.warning(f"Received unknown admin action: {action}")
|
| 1711 |
flash(f"Неизвестное действие: {action}", 'warning')
|
| 1712 |
-
|
| 1713 |
return redirect(url_for('admin'))
|
| 1714 |
-
|
| 1715 |
except Exception as e:
|
| 1716 |
logging.error(f"Error processing admin action '{action}': {e}", exc_info=True)
|
| 1717 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1718 |
return redirect(url_for('admin'))
|
| 1719 |
-
|
| 1720 |
-
# --- GET request ---
|
| 1721 |
current_data = load_data()
|
| 1722 |
display_products = sorted(current_data.get('products', []), key=lambda p: p.get('name', '').lower())
|
| 1723 |
display_categories = sorted(current_data.get('categories', []))
|
| 1724 |
-
|
| 1725 |
return render_template_string(
|
| 1726 |
ADMIN_TEMPLATE,
|
| 1727 |
products=display_products,
|
|
@@ -1729,7 +1558,6 @@ def admin():
|
|
| 1729 |
repo_id=REPO_ID,
|
| 1730 |
currency_code=CURRENCY_CODE
|
| 1731 |
)
|
| 1732 |
-
|
| 1733 |
@app.route('/force_upload', methods=['POST'])
|
| 1734 |
def force_upload():
|
| 1735 |
logging.info("Forcing upload to Hugging Face...")
|
|
@@ -1740,39 +1568,30 @@ def force_upload():
|
|
| 1740 |
logging.error(f"Error during forced upload: {e}", exc_info=True)
|
| 1741 |
flash(f"Ошибка при загрузке на Hugging Face: {e}", 'error')
|
| 1742 |
return redirect(url_for('admin'))
|
| 1743 |
-
|
| 1744 |
@app.route('/force_download', methods=['POST'])
|
| 1745 |
def force_download():
|
| 1746 |
logging.info("Forcing download from Hugging Face...")
|
| 1747 |
try:
|
| 1748 |
if download_db_from_hf():
|
| 1749 |
flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success')
|
| 1750 |
-
load_data()
|
| 1751 |
else:
|
| 1752 |
flash("Не удалось скачать данные с Hugging Face после нескольких попыток. Проверьте логи.", 'error')
|
| 1753 |
except Exception as e:
|
| 1754 |
logging.error(f"Error during forced download: {e}", exc_info=True)
|
| 1755 |
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
|
| 1756 |
return redirect(url_for('admin'))
|
| 1757 |
-
|
| 1758 |
-
|
| 1759 |
-
# --- App Initialization ---
|
| 1760 |
-
|
| 1761 |
if __name__ == '__main__':
|
| 1762 |
logging.info("Application starting up. Performing initial data load/download...")
|
| 1763 |
download_db_from_hf()
|
| 1764 |
load_data()
|
| 1765 |
logging.info("Initial data load complete.")
|
| 1766 |
-
|
| 1767 |
if HF_TOKEN_WRITE:
|
| 1768 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 1769 |
backup_thread.start()
|
| 1770 |
logging.info("Periodic backup thread started.")
|
| 1771 |
else:
|
| 1772 |
logging.warning("Periodic backup will NOT run (HF_TOKEN for writing not set).")
|
| 1773 |
-
|
| 1774 |
port = int(os.environ.get('PORT', 7860))
|
| 1775 |
logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
|
| 1776 |
app.run(debug=False, host='0.0.0.0', port=port)
|
| 1777 |
-
|
| 1778 |
-
# --- END OF FILE app.py ---
|
|
|
|
| 1 |
|
|
|
|
|
|
|
| 2 |
from flask import Flask, render_template_string, request, redirect, url_for, send_file, flash, jsonify
|
| 3 |
import json
|
| 4 |
import os
|
|
|
|
| 12 |
from dotenv import load_dotenv
|
| 13 |
import requests
|
| 14 |
import uuid
|
|
|
|
| 15 |
load_dotenv()
|
|
|
|
| 16 |
app = Flask(__name__)
|
| 17 |
app.secret_key = 'your_unique_secret_key_soola_cosmetics_67890_no_login'
|
| 18 |
DATA_FILE = 'data.json'
|
|
|
|
|
|
|
| 19 |
SYNC_FILES = [DATA_FILE]
|
|
|
|
| 20 |
REPO_ID = "Kgshop/nizhbel"
|
| 21 |
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
|
| 22 |
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
|
|
|
| 23 |
STORE_ADDRESS = "Рынок Кербент, 6 ряд , 3 контейнер / 5 ряд 25 контейнер "
|
|
|
|
| 24 |
CURRENCY_CODE = 'KGS'
|
| 25 |
CURRENCY_NAME = 'Кыргызский сом'
|
|
|
|
| 26 |
DOWNLOAD_RETRIES = 3
|
| 27 |
+
DOWNLOAD_DELAY = 5
|
| 28 |
+
WHATSAPP_NUMBER = "996509455959"
|
| 29 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
|
|
|
|
|
|
|
|
| 30 |
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
|
| 31 |
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
|
| 32 |
logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
|
|
|
|
| 33 |
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
|
|
|
|
| 34 |
files_to_download = [specific_file] if specific_file else SYNC_FILES
|
| 35 |
logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
|
| 36 |
all_successful = True
|
|
|
|
| 37 |
for file_name in files_to_download:
|
| 38 |
success = False
|
| 39 |
for attempt in range(retries + 1):
|
|
|
|
| 74 |
logging.error(f"Network error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
|
| 75 |
except Exception as e:
|
| 76 |
logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
|
|
|
|
| 77 |
if attempt < retries:
|
| 78 |
time.sleep(delay)
|
|
|
|
| 79 |
if not success:
|
| 80 |
logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
|
| 81 |
all_successful = False
|
|
|
|
| 82 |
logging.info(f"Download process finished. Overall success: {all_successful}")
|
| 83 |
return all_successful
|
|
|
|
| 84 |
def upload_db_to_hf(specific_file=None):
|
| 85 |
if not HF_TOKEN_WRITE:
|
| 86 |
logging.warning("HF_TOKEN (for writing) not set. Skipping upload to Hugging Face.")
|
|
|
|
| 89 |
api = HfApi()
|
| 90 |
files_to_upload = [specific_file] if specific_file else SYNC_FILES
|
| 91 |
logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
|
|
|
|
| 92 |
for file_name in files_to_upload:
|
| 93 |
if os.path.exists(file_name):
|
| 94 |
try:
|
|
|
|
| 108 |
logging.info("Finished uploading files to HF.")
|
| 109 |
except Exception as e:
|
| 110 |
logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
|
|
|
|
| 111 |
def periodic_backup():
|
| 112 |
backup_interval = 1800
|
| 113 |
logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
|
|
|
|
| 116 |
logging.info("Starting periodic backup...")
|
| 117 |
upload_db_to_hf()
|
| 118 |
logging.info("Periodic backup finished.")
|
|
|
|
|
|
|
|
|
|
| 119 |
def load_data():
|
| 120 |
default_data = {'products': [], 'categories': [], 'orders': {}}
|
| 121 |
try:
|
|
|
|
| 133 |
logging.warning(f"Local file {DATA_FILE} not found. Attempting download from HF.")
|
| 134 |
except json.JSONDecodeError:
|
| 135 |
logging.error(f"Error decoding JSON in local {DATA_FILE}. File might be corrupt. Attempting download.")
|
|
|
|
| 136 |
if download_db_from_hf(specific_file=DATA_FILE):
|
| 137 |
try:
|
| 138 |
with open(DATA_FILE, 'r', encoding='utf-8') as file:
|
|
|
|
| 164 |
except Exception as create_e:
|
| 165 |
logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}")
|
| 166 |
return default_data
|
|
|
|
| 167 |
def save_data(data):
|
| 168 |
try:
|
| 169 |
if not isinstance(data, dict):
|
|
|
|
| 172 |
if 'products' not in data: data['products'] = []
|
| 173 |
if 'categories' not in data: data['categories'] = []
|
| 174 |
if 'orders' not in data: data['orders'] = {}
|
|
|
|
| 175 |
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 176 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 177 |
logging.info(f"Data successfully saved to {DATA_FILE}")
|
| 178 |
upload_db_to_hf(specific_file=DATA_FILE)
|
| 179 |
except Exception as e:
|
| 180 |
logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
|
|
|
|
|
|
|
|
|
|
| 181 |
CATALOG_TEMPLATE = '''
|
| 182 |
<!DOCTYPE html>
|
| 183 |
<html lang="ru">
|
| 184 |
<head>
|
| 185 |
<meta charset="UTF-8">
|
| 186 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 187 |
+
<title>Meka Shop - Каталог</title>
|
| 188 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 189 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 190 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
|
| 191 |
<style>
|
| 192 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 193 |
+
body { font-family: 'Poppins', sans-serif; background: #d7f7e0; color: #1a362d; line-height: 1.6; transition: background 0.3s, color 0.3s; }
|
| 194 |
+
body.dark-mode { background: #1a362d; color: #d7f7e0; }
|
| 195 |
.container { max-width: 1300px; margin: 0 auto; padding: 20px; }
|
| 196 |
+
.header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #9cdbb0; }
|
| 197 |
+
body.dark-mode .header { border-bottom-color: #4f6a5a; }
|
| 198 |
+
.header h1 { font-size: 1.8rem; font-weight: 600; color: #4CAF50; }
|
| 199 |
+
.theme-toggle { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #7ed99c; transition: color 0.3s ease; }
|
| 200 |
+
.theme-toggle:hover { color: #388E3C; }
|
| 201 |
+
body.dark-mode .theme-toggle { color: #a7f3d0; }
|
| 202 |
+
body.dark-mode .theme-toggle:hover { color: #d7f7e0; }
|
| 203 |
+
.store-address { padding: 15px; text-align: center; background-color: #ffffff; margin: 20px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); font-size: 1rem; color: #4f6a5a; }
|
| 204 |
+
body.dark-mode .store-address { background-color: #0f231c; color: #d7f7e0; }
|
| 205 |
.filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
| 206 |
.search-container { margin: 20px 0; text-align: center; }
|
| 207 |
+
#search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid #9cdbb0; border-radius: 25px; outline: none; box-shadow: 0 2px 5px rgba(0,0,0,0.05); transition: all 0.3s ease; }
|
| 208 |
+
body.dark-mode #search-input { background-color: #0f231c; border-color: #4f6a5a; color: #d7f7e0; }
|
| 209 |
+
#search-input:focus { border-color: #4CAF50; box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.2); }
|
| 210 |
+
body.dark-mode #search-input:focus { border-color: #388E3C; box-shadow: 0 0 0 3px rgba(56, 142, 60, 0.3); }
|
| 211 |
+
.category-filter { padding: 8px 16px; border: 1px solid #9cdbb0; border-radius: 20px; background-color: #fff; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); font-size: 0.9rem; font-weight: 400; color: #388E3C; }
|
| 212 |
+
body.dark-mode .category-filter { background-color: #0f231c; border-color: #4f6a5a; color: #a7f3d0; }
|
| 213 |
+
.category-filter.active, .category-filter:hover { background-color: #4CAF50; color: white; border-color: #4CAF50; box-shadow: 0 2px 10px rgba(76, 175, 80, 0.3); }
|
| 214 |
+
body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover { background-color: #388E3C; border-color: #388E3C; color: #1a362d; box-shadow: 0 2px 10px rgba(56, 142, 60, 0.4); }
|
| 215 |
.products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 20px; padding: 10px; }
|
| 216 |
@media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } }
|
| 217 |
@media (min-width: 900px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
|
| 218 |
+
.product { background: #fff; 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%; border: 1px solid #c1e7cc;}
|
| 219 |
+
body.dark-mode .product { background: #0f231c; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #4f6a5a; }
|
|
|
|
| 220 |
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
|
| 221 |
body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
|
| 222 |
.product-image { width: 100%; aspect-ratio: 1 / 1; background-color: #fff; border-radius: 10px 10px 0 0; overflow: hidden; display: flex; justify-content: center; align-items: center; margin-bottom: 0; }
|
| 223 |
.product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; }
|
| 224 |
.product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
|
| 225 |
+
.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: #1a362d; }
|
| 226 |
+
body.dark-mode .product h2 { color: #d7f7e0; }
|
| 227 |
+
.product-price { font-size: 1.2rem; color: #388E3C; font-weight: 700; text-align: center; margin: 5px 0; }
|
| 228 |
+
body.dark-mode .product-price { color: #a7f3d0; }
|
| 229 |
+
.product-description { font-size: 0.85rem; color: #4f6a5a; text-align: center; margin-bottom: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 230 |
+
body.dark-mode .product-description { color: #c1e7cc; }
|
| 231 |
.product-actions { padding: 0 15px 15px 15px; display: flex; flex-direction: column; gap: 8px; }
|
| 232 |
+
.product-button { display: block; width: 100%; padding: 10px; border: none; border-radius: 8px; background-color: #4CAF50; 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; }
|
| 233 |
+
.product-button:hover { background-color: #388E3C; box-shadow: 0 4px 15px rgba(56, 142, 60, 0.4); transform: translateY(-2px); }
|
| 234 |
.product-button i { margin-right: 5px; }
|
| 235 |
+
.add-to-cart { background-color: #4CAF50; }
|
| 236 |
+
.add-to-cart:hover { background-color: #388E3C; box-shadow: 0 4px 15px rgba(56, 142, 60, 0.4); }
|
| 237 |
+
#cart-button { position: fixed; bottom: 25px; right: 25px; background-color: #4CAF50; 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(76, 175, 80, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; }
|
| 238 |
#cart-button .fa-shopping-cart { margin-right: 0; }
|
| 239 |
+
#cart-button span { position: absolute; top: -5px; right: -5px; background-color: #388E3C; color: white; border-radius: 50%; padding: 2px 6px; font-size: 0.7rem; font-weight: bold; }
|
| 240 |
.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; }
|
| 241 |
.modal-content { background: #ffffff; 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; }
|
| 242 |
+
body.dark-mode .modal-content { background: #0f231c; color: #d7f7e0; }
|
| 243 |
@keyframes slideIn { from { transform: translateY(-30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
| 244 |
.close { position: absolute; top: 15px; right: 15px; font-size: 1.8rem; color: #aaa; cursor: pointer; transition: color 0.3s; line-height: 1; }
|
| 245 |
.close:hover { color: #333; }
|
| 246 |
+
body.dark-mode .close { color: #c1e7cc; }
|
| 247 |
body.dark-mode .close:hover { color: #ffffff; }
|
| 248 |
+
.modal-content h2 { margin-top: 0; margin-bottom: 20px; color: #4CAF50; display: flex; align-items: center; gap: 10px;}
|
| 249 |
+
body.dark-mode .modal-content h2 { color: #a7f3d0; }
|
| 250 |
+
.cart-item { display: grid; grid-template-columns: auto 1fr auto auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #9cdbb0; }
|
| 251 |
+
body.dark-mode .cart-item { border-bottom-color: #4f6a5a; }
|
| 252 |
.cart-item:last-child { border-bottom: none; }
|
| 253 |
.cart-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; grid-column: 1; }
|
| 254 |
.cart-item-details { grid-column: 2; }
|
| 255 |
.cart-item-details strong { display: block; margin-bottom: 5px; font-size: 1rem; }
|
| 256 |
+
.cart-item-price { font-size: 0.9rem; color: #4f6a5a; }
|
| 257 |
+
body.dark-mode .cart-item-price { color: #c1e7cc; }
|
| 258 |
.cart-item-total { font-weight: bold; text-align: right; grid-column: 3; font-size: 1rem;}
|
| 259 |
.cart-item-remove { grid-column: 4; background:none; border:none; color:#f56565; cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; }
|
| 260 |
.cart-item-remove:hover { color: #c53030; }
|
| 261 |
+
.quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #9cdbb0; border-radius: 8px; font-size: 1rem; margin: 10px 0; box-sizing: border-box; }
|
| 262 |
+
body.dark-mode .quantity-input, body.dark-mode .color-select { background-color: #1a362d; border-color: #4f6a5a; color: #d7f7e0; }
|
| 263 |
+
.cart-summary { margin-top: 20px; text-align: right; border-top: 1px solid #9cdbb0; padding-top: 15px; }
|
| 264 |
+
body.dark-mode .cart-summary { border-top-color: #4f6a5a; }
|
| 265 |
.cart-summary strong { font-size: 1.2rem; }
|
| 266 |
.cart-actions { margin-top: 25px; display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
| 267 |
.cart-actions .product-button { width: auto; flex-grow: 1; }
|
| 268 |
+
.clear-cart { background-color: #a0aec0; }
|
| 269 |
.clear-cart:hover { background-color: #718096; box-shadow: 0 4px 15px rgba(113, 128, 150, 0.4); }
|
| 270 |
+
.formulate-order-button { background-color: #4CAF50; }
|
| 271 |
+
.formulate-order-button:hover { background-color: #388E3C; box-shadow: 0 4px 15px rgba(56, 142, 60, 0.4); }
|
| 272 |
+
.notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: #4CAF50; 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;}
|
| 273 |
.notification.show { opacity: 1;}
|
| 274 |
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #a0aec0; }
|
| 275 |
+
body.dark-mode .no-results-message { color: #c1e7cc; }
|
| 276 |
.top-product-indicator { position: absolute; top: 8px; right: 8px; background-color: rgba(255, 215, 0, 0.8); color: #333; padding: 2px 6px; font-size: 0.7rem; border-radius: 4px; font-weight: bold; z-index: 10; backdrop-filter: blur(2px); }
|
| 277 |
.product { position: relative; }
|
| 278 |
</style>
|
|
|
|
| 281 |
<div class="container">
|
| 282 |
<div class="header">
|
| 283 |
<div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;">
|
| 284 |
+
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Apple-logo.png/2560px-Apple-logo.png" alt="Meka Shop Logo" style="height: 40px; width: auto; border-radius: 4px;">
|
| 285 |
+
<h1>Meka Shop</h1>
|
| 286 |
</div>
|
| 287 |
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Переключить тему">
|
| 288 |
<i class="fas fa-moon"></i>
|
| 289 |
</button>
|
| 290 |
</div>
|
|
|
|
| 291 |
<div class="store-address">Наш адрес: {{ store_address }}</div>
|
|
|
|
| 292 |
<div class="filters-container">
|
| 293 |
<button class="category-filter active" data-category="all">Все категории</button>
|
| 294 |
{% for category in categories %}
|
| 295 |
<button class="category-filter" data-category="{{ category }}">{{ category }}</button>
|
| 296 |
{% endfor %}
|
| 297 |
</div>
|
|
|
|
| 298 |
<div class="search-container">
|
| 299 |
<input type="text" id="search-input" placeholder="Поиск по названию или описанию...">
|
| 300 |
</div>
|
|
|
|
| 301 |
<div class="products-grid" id="products-grid">
|
| 302 |
{% for product in products %}
|
| 303 |
<div class="product"
|
|
|
|
| 334 |
{% endif %}
|
| 335 |
</div>
|
| 336 |
</div>
|
|
|
|
| 337 |
<div id="productModal" class="modal">
|
| 338 |
<div class="modal-content">
|
| 339 |
<span class="close" onclick="closeModal('productModal')" aria-label="Закрыть">×</span>
|
| 340 |
<div id="modalContent">Загрузка...</div>
|
| 341 |
</div>
|
| 342 |
</div>
|
|
|
|
| 343 |
<div id="quantityModal" class="modal">
|
| 344 |
<div class="modal-content">
|
| 345 |
<span class="close" onclick="closeModal('quantityModal')" aria-label="Закрыть">×</span>
|
|
|
|
| 351 |
<button class="product-button add-to-cart" onclick="confirmAddToCart()"><i class="fas fa-check"></i> Добавить в корзину</button>
|
| 352 |
</div>
|
| 353 |
</div>
|
|
|
|
| 354 |
<div id="cartModal" class="modal">
|
| 355 |
<div class="modal-content">
|
| 356 |
<span class="close" onclick="closeModal('cartModal')" aria-label="Закрыть">×</span>
|
|
|
|
| 369 |
</div>
|
| 370 |
</div>
|
| 371 |
</div>
|
|
|
|
| 372 |
<button id="cart-button" onclick="openCartModal()" aria-label="Открыть корзину">
|
| 373 |
<i class="fas fa-shopping-cart"></i>
|
| 374 |
<span id="cart-count">0</span>
|
| 375 |
</button>
|
|
|
|
| 376 |
<div id="notification-placeholder"></div>
|
|
|
|
| 377 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
|
| 378 |
<script>
|
| 379 |
const products = {{ products|tojson }};
|
|
|
|
| 381 |
const currencyCode = '{{ currency_code }}';
|
| 382 |
let selectedProductIndex = null;
|
| 383 |
let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
|
|
|
|
| 384 |
function toggleTheme() {
|
| 385 |
document.body.classList.toggle('dark-mode');
|
| 386 |
const icon = document.querySelector('.theme-toggle i');
|
|
|
|
| 389 |
icon.classList.toggle('fa-sun', isDarkMode);
|
| 390 |
localStorage.setItem('soolaTheme', isDarkMode ? 'dark' : 'light');
|
| 391 |
}
|
|
|
|
| 392 |
function applyInitialTheme() {
|
| 393 |
const savedTheme = localStorage.getItem('soolaTheme');
|
| 394 |
if (savedTheme === 'dark') {
|
|
|
|
| 397 |
if (icon) icon.classList.replace('fa-moon', 'fa-sun');
|
| 398 |
}
|
| 399 |
}
|
|
|
|
| 400 |
function openModal(index) {
|
| 401 |
loadProductDetails(index);
|
| 402 |
const modal = document.getElementById('productModal');
|
|
|
|
| 405 |
document.body.style.overflow = 'hidden';
|
| 406 |
}
|
| 407 |
}
|
|
|
|
| 408 |
function closeModal(modalId) {
|
| 409 |
const modal = document.getElementById(modalId);
|
| 410 |
if (modal) {
|
|
|
|
| 415 |
document.body.style.overflow = 'auto';
|
| 416 |
}
|
| 417 |
}
|
|
|
|
| 418 |
function loadProductDetails(index) {
|
| 419 |
const modalContent = document.getElementById('modalContent');
|
| 420 |
if (!modalContent) return;
|
|
|
|
| 433 |
modalContent.innerHTML = `<p style="color: red; text-align:center; padding: 40px;">Не удалось загрузить информацию о товаре. ${error.message}</p>`;
|
| 434 |
});
|
| 435 |
}
|
|
|
|
| 436 |
function initializeSwiper() {
|
| 437 |
const swiperContainer = document.querySelector('#productModal .swiper-container');
|
| 438 |
if (swiperContainer) {
|
|
|
|
| 448 |
});
|
| 449 |
}
|
| 450 |
}
|
|
|
|
| 451 |
function openQuantityModal(index) {
|
| 452 |
selectedProductIndex = index;
|
| 453 |
const product = products[index];
|
|
|
|
| 456 |
alert("Ошибка: товар не найден.");
|
| 457 |
return;
|
| 458 |
}
|
|
|
|
| 459 |
const colorSelect = document.getElementById('colorSelect');
|
| 460 |
const colorLabel = document.querySelector('label[for="colorSelect"]');
|
| 461 |
colorSelect.innerHTML = '';
|
|
|
|
| 462 |
const validColors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : [];
|
|
|
|
| 463 |
if (validColors.length > 0) {
|
| 464 |
validColors.forEach(color => {
|
| 465 |
const option = document.createElement('option');
|
|
|
|
| 473 |
colorSelect.style.display = 'none';
|
| 474 |
if(colorLabel) colorLabel.style.display = 'none';
|
| 475 |
}
|
|
|
|
| 476 |
document.getElementById('quantityInput').value = 1;
|
| 477 |
const modal = document.getElementById('quantityModal');
|
| 478 |
if(modal) {
|
|
|
|
| 480 |
document.body.style.overflow = 'hidden';
|
| 481 |
}
|
| 482 |
}
|
|
|
|
| 483 |
function confirmAddToCart() {
|
| 484 |
if (selectedProductIndex === null) return;
|
|
|
|
| 485 |
const quantityInput = document.getElementById('quantityInput');
|
| 486 |
const quantity = parseInt(quantityInput.value);
|
| 487 |
const colorSelect = document.getElementById('colorSelect');
|
| 488 |
const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A';
|
|
|
|
| 489 |
if (isNaN(quantity) || quantity <= 0) {
|
| 490 |
alert("Пожалуйста, укажите корректное количество (больше 0).");
|
| 491 |
quantityInput.focus();
|
| 492 |
return;
|
| 493 |
}
|
|
|
|
| 494 |
const product = products[selectedProductIndex];
|
| 495 |
if (!product) {
|
| 496 |
alert("Ошибка добавления: товар не найден.");
|
| 497 |
return;
|
| 498 |
}
|
| 499 |
+
const cartItemId = `${product.name}-${color}`;
|
|
|
|
| 500 |
const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
|
|
|
|
| 501 |
if (existingItemIndex > -1) {
|
| 502 |
cart[existingItemIndex].quantity += quantity;
|
| 503 |
} else {
|
|
|
|
| 510 |
color: color
|
| 511 |
});
|
| 512 |
}
|
|
|
|
| 513 |
localStorage.setItem('soolaCart', JSON.stringify(cart));
|
| 514 |
closeModal('quantityModal');
|
| 515 |
updateCartButton();
|
| 516 |
showNotification(`${product.name} добавлен в корзину!`);
|
| 517 |
}
|
|
|
|
| 518 |
function updateCartButton() {
|
| 519 |
const cartCountElement = document.getElementById('cart-count');
|
| 520 |
const cartButton = document.getElementById('cart-button');
|
| 521 |
if (!cartCountElement || !cartButton) return;
|
|
|
|
| 522 |
let totalItems = 0;
|
| 523 |
cart.forEach(item => { totalItems += item.quantity; });
|
|
|
|
| 524 |
if (totalItems > 0) {
|
| 525 |
cartCountElement.textContent = totalItems;
|
| 526 |
cartButton.style.display = 'flex';
|
|
|
|
| 529 |
cartButton.style.display = 'none';
|
| 530 |
}
|
| 531 |
}
|
|
|
|
| 532 |
function openCartModal() {
|
| 533 |
const cartContent = document.getElementById('cartContent');
|
| 534 |
const cartTotalElement = document.getElementById('cartTotal');
|
| 535 |
if (!cartContent || !cartTotalElement) return;
|
|
|
|
| 536 |
let total = 0;
|
|
|
|
| 537 |
if (cart.length === 0) {
|
| 538 |
cartContent.innerHTML = '<p style="text-align: center; padding: 20px;">Ваша корзина пуста.</p>';
|
| 539 |
cartTotalElement.textContent = '0.00';
|
|
|
|
| 545 |
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}`
|
| 546 |
: 'https://via.placeholder.com/60x60.png?text=N/A';
|
| 547 |
const colorText = item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
|
|
|
|
| 548 |
return `
|
| 549 |
<div class="cart-item">
|
| 550 |
<img src="${photoUrl}" alt="${item.name}">
|
|
|
|
| 565 |
document.body.style.overflow = 'hidden';
|
| 566 |
}
|
| 567 |
}
|
|
|
|
| 568 |
function removeFromCart(itemId) {
|
| 569 |
cart = cart.filter(item => item.id !== itemId);
|
| 570 |
localStorage.setItem('soolaCart', JSON.stringify(cart));
|
| 571 |
openCartModal();
|
| 572 |
updateCartButton();
|
| 573 |
}
|
|
|
|
| 574 |
function clearCart() {
|
| 575 |
if (confirm("Вы уверены, что хотите очистить корзину?")) {
|
| 576 |
cart = [];
|
|
|
|
| 579 |
updateCartButton();
|
| 580 |
}
|
| 581 |
}
|
|
|
|
| 582 |
function formulateOrder() {
|
| 583 |
if (cart.length === 0) {
|
| 584 |
alert("Корзина пуста! Добавьте товары перед формированием заказа.");
|
| 585 |
return;
|
| 586 |
}
|
|
|
|
| 587 |
const orderData = {
|
| 588 |
cart: cart
|
| 589 |
};
|
|
|
|
| 590 |
const formulateButton = document.querySelector('.formulate-order-button');
|
| 591 |
if (formulateButton) formulateButton.disabled = true;
|
|
|
|
| 592 |
showNotification("Формируем заказ...", 5000);
|
|
|
|
| 593 |
fetch('/create_order', {
|
| 594 |
method: 'POST',
|
| 595 |
headers: { 'Content-Type': 'application/json' },
|
|
|
|
| 618 |
if (formulateButton) formulateButton.disabled = false;
|
| 619 |
});
|
| 620 |
}
|
|
|
|
|
|
|
| 621 |
function filterProducts() {
|
| 622 |
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
|
| 623 |
const activeCategoryButton = document.querySelector('.category-filter.active');
|
| 624 |
const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all';
|
| 625 |
const grid = document.getElementById('products-grid');
|
| 626 |
let visibleProducts = 0;
|
|
|
|
| 627 |
const existingNoResults = grid.querySelector('.no-results-message');
|
| 628 |
if (existingNoResults) existingNoResults.remove();
|
|
|
|
| 629 |
document.querySelectorAll('.products-grid .product').forEach(productElement => {
|
| 630 |
const name = productElement.getAttribute('data-name');
|
| 631 |
const description = productElement.getAttribute('data-description');
|
| 632 |
const category = productElement.getAttribute('data-category');
|
|
|
|
| 633 |
const matchesSearch = !searchTerm || name.includes(searchTerm) || description.includes(searchTerm);
|
| 634 |
const matchesCategory = activeCategory === 'all' || category === activeCategory;
|
|
|
|
| 635 |
if (matchesSearch && matchesCategory) {
|
| 636 |
productElement.style.display = 'flex';
|
| 637 |
visibleProducts++;
|
|
|
|
| 639 |
productElement.style.display = 'none';
|
| 640 |
}
|
| 641 |
});
|
|
|
|
| 642 |
if (visibleProducts === 0 && products.length > 0) {
|
| 643 |
const p = document.createElement('p');
|
| 644 |
p.className = 'no-results-message';
|
|
|
|
| 651 |
grid.appendChild(p);
|
| 652 |
}
|
| 653 |
}
|
|
|
|
| 654 |
function setupFilters() {
|
| 655 |
const searchInput = document.getElementById('search-input');
|
| 656 |
const categoryFilters = document.querySelectorAll('.category-filter');
|
|
|
|
| 657 |
if(searchInput) searchInput.addEventListener('input', filterProducts);
|
|
|
|
| 658 |
categoryFilters.forEach(filter => {
|
| 659 |
filter.addEventListener('click', function() {
|
| 660 |
categoryFilters.forEach(f => f.classList.remove('active'));
|
|
|
|
| 664 |
});
|
| 665 |
filterProducts();
|
| 666 |
}
|
|
|
|
| 667 |
function showNotification(message, duration = 3000) {
|
| 668 |
const placeholder = document.getElementById('notification-placeholder');
|
| 669 |
if (!placeholder) {
|
|
|
|
| 677 |
document.body.appendChild(newPlaceholder);
|
| 678 |
placeholder = newPlaceholder;
|
| 679 |
}
|
|
|
|
|
|
|
| 680 |
const notification = document.createElement('div');
|
| 681 |
notification.className = 'notification';
|
| 682 |
notification.textContent = message;
|
| 683 |
placeholder.appendChild(notification);
|
|
|
|
| 684 |
void notification.offsetWidth;
|
|
|
|
| 685 |
notification.classList.add('show');
|
|
|
|
| 686 |
setTimeout(() => {
|
| 687 |
notification.classList.remove('show');
|
| 688 |
notification.addEventListener('transitionend', () => notification.remove());
|
| 689 |
}, duration);
|
| 690 |
}
|
|
|
|
| 691 |
document.addEventListener('DOMContentLoaded', () => {
|
| 692 |
applyInitialTheme();
|
| 693 |
updateCartButton();
|
| 694 |
setupFilters();
|
|
|
|
| 695 |
window.addEventListener('click', function(event) {
|
| 696 |
if (event.target.classList.contains('modal')) {
|
| 697 |
closeModal(event.target.id);
|
| 698 |
}
|
| 699 |
});
|
|
|
|
| 700 |
window.addEventListener('keydown', function(event) {
|
| 701 |
if (event.key === 'Escape') {
|
| 702 |
document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
|
|
|
|
| 705 |
}
|
| 706 |
});
|
| 707 |
});
|
|
|
|
| 708 |
</script>
|
| 709 |
</body>
|
| 710 |
</html>
|
| 711 |
'''
|
|
|
|
| 712 |
PRODUCT_DETAIL_TEMPLATE = '''
|
| 713 |
<div style="padding: 10px;">
|
| 714 |
+
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #4CAF50;">{{ product['name'] }}</h2>
|
| 715 |
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
|
| 716 |
<div class="swiper-wrapper">
|
| 717 |
{% if product.get('photos') and product['photos']|length > 0 %}
|
|
|
|
| 732 |
</div>
|
| 733 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 734 |
<div class="swiper-pagination" style="position: relative; bottom: 5px;"></div>
|
| 735 |
+
<div class="swiper-button-next" style="color: #4CAF50;"></div>
|
| 736 |
+
<div class="swiper-button-prev" style="color: #4CAF50;"></div>
|
| 737 |
{% endif %}
|
| 738 |
</div>
|
|
|
|
| 739 |
<div style="margin-top: 20px; font-size: 1rem; line-height: 1.7;">
|
| 740 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 741 |
+
<p style="font-size: 1.2rem; font-weight: bold; color: #388E3C;"><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 742 |
<p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\\n', '<br>')|safe }}</p>
|
| 743 |
{% set colors = product.get('colors', []) %}
|
| 744 |
{% if colors and colors|select('ne', '')|list|length > 0 %}
|
|
|
|
| 747 |
</div>
|
| 748 |
</div>
|
| 749 |
'''
|
|
|
|
| 750 |
ORDER_TEMPLATE = '''
|
| 751 |
<!DOCTYPE html>
|
| 752 |
<html lang="ru">
|
| 753 |
<head>
|
| 754 |
<meta charset="UTF-8">
|
| 755 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 756 |
+
<title>Заказ №{{ order.id }} - Meka Shop</title>
|
| 757 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 758 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 759 |
<style>
|
| 760 |
+
body { font-family: 'Poppins', sans-serif; background: #d7f7e0; color: #1a362d; line-height: 1.6; padding: 20px; }
|
| 761 |
+
.container { max-width: 800px; margin: 20px auto; padding: 30px; background: #fff; border-radius: 15px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); border: 1px solid #c1e7cc; }
|
| 762 |
+
h1 { text-align: center; color: #4CAF50; margin-bottom: 25px; font-size: 1.8rem; font-weight: 600; }
|
| 763 |
+
h2 { color: #388E3C; margin-top: 30px; margin-bottom: 15px; font-size: 1.4rem; border-bottom: 1px solid #9cdbb0; padding-bottom: 8px;}
|
| 764 |
.order-meta { font-size: 0.9rem; color: #a0aec0; margin-bottom: 20px; text-align: center; }
|
| 765 |
+
.order-item { display: grid; grid-template-columns: 60px 1fr auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #c1e7cc; }
|
| 766 |
.order-item:last-child { border-bottom: none; }
|
| 767 |
+
.order-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; border: 1px solid #c1e7cc;}
|
| 768 |
+
.item-details strong { display: block; margin-bottom: 5px; font-size: 1.05rem; color: #1a362d;}
|
| 769 |
+
.item-details span { font-size: 0.9rem; color: #4f6a5a; display: block;}
|
| 770 |
+
.item-total { font-weight: bold; text-align: right; font-size: 1rem; color: #388E3C;}
|
| 771 |
+
.order-summary { margin-top: 30px; padding-top: 20px; border-top: 2px solid #4CAF50; text-align: right; }
|
| 772 |
.order-summary p { margin-bottom: 10px; font-size: 1.1rem; }
|
| 773 |
+
.order-summary strong { font-size: 1.3rem; color: #4CAF50; }
|
| 774 |
+
.customer-info { margin-top: 30px; background-color: #fff; padding: 20px; border-radius: 8px; border: 1px solid #c1e7cc;}
|
| 775 |
.customer-info p { margin-bottom: 8px; font-size: 0.95rem; }
|
| 776 |
+
.customer-info strong { color: #388E3C; }
|
| 777 |
.actions { margin-top: 30px; text-align: center; }
|
| 778 |
+
.button { padding: 12px 25px; border: none; border-radius: 8px; background-color: #4CAF50; color: white; font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; font-size: 1rem; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; }
|
| 779 |
+
.button:hover { background-color: #388E3C; }
|
| 780 |
.button:active { transform: scale(0.98); }
|
| 781 |
.button i { font-size: 1.2rem; }
|
| 782 |
+
.catalog-link { display: block; text-align: center; margin-top: 25px; color: #388E3C; text-decoration: none; font-size: 0.9rem; }
|
| 783 |
.catalog-link:hover { text-decoration: underline; }
|
| 784 |
.not-found { text-align: center; color: #c53030; font-size: 1.2rem; padding: 40px 0;}
|
| 785 |
</style>
|
|
|
|
| 789 |
{% if order %}
|
| 790 |
<h1><i class="fas fa-receipt"></i> Ваш Заказ №{{ order.id }}</h1>
|
| 791 |
<p class="order-meta">Дата создания: {{ order.created_at }}</p>
|
|
|
|
| 792 |
<h2><i class="fas fa-shopping-bag"></i> Товары в заказе</h2>
|
| 793 |
<div id="orderItems">
|
| 794 |
{% for item in order.cart %}
|
|
|
|
| 804 |
</div>
|
| 805 |
{% endfor %}
|
| 806 |
</div>
|
|
|
|
| 807 |
<div class="order-summary">
|
| 808 |
<p>Общая сумма товаров: <strong>{{ "%.2f"|format(order.total_price) }} {{ currency_code }}</strong></p>
|
| 809 |
<p><strong>ИТОГО К ОПЛАТЕ: {{ "%.2f"|format(order.total_price) }} {{ currency_code }}</strong></p>
|
| 810 |
</div>
|
|
|
|
| 811 |
<div class="customer-info">
|
| 812 |
<h2><i class="fas fa-info-circle"></i> Статус заказа</h2>
|
| 813 |
<p>Этот заказ был оформлен без входа в систему.</p>
|
| 814 |
<p>Пожалуйста, свяжитесь с нами по WhatsApp для подтверждения и уточнения деталей.</p>
|
| 815 |
</div>
|
|
|
|
| 816 |
<div class="actions">
|
| 817 |
<button class="button" onclick="sendOrderViaWhatsApp()"><i class="fab fa-whatsapp"></i> Отправить заказ</button>
|
| 818 |
</div>
|
|
|
|
| 819 |
<a href="{{ url_for('catalog') }}" class="catalog-link">← Вернуться в каталог</a>
|
|
|
|
| 820 |
<script>
|
| 821 |
function sendOrderViaWhatsApp() {
|
| 822 |
const orderId = '{{ order.id }}';
|
| 823 |
const orderUrl = `{{ request.url }}`;
|
| 824 |
+
const whatsappNumber = "{{ whatsapp_number }}";
|
| 825 |
+
let message = `Здравствуйте! Хочу подтвердить свой заказ на Meka Shop:%0A%0A`;
|
|
|
|
| 826 |
message += `*Номер заказа:* ${orderId}%0A`;
|
| 827 |
message += `*Ссылка на заказ:* ${encodeURIComponent(orderUrl)}%0A%0A`;
|
| 828 |
message += `Пожалуйста, свяжитесь со мной для уточнения деталей оплаты и доставки.`;
|
|
|
|
| 829 |
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${message}`;
|
| 830 |
window.open(whatsappUrl, '_blank');
|
| 831 |
}
|
| 832 |
</script>
|
|
|
|
| 833 |
{% else %}
|
| 834 |
<h1 style="color: #c53030;"><i class="fas fa-exclamation-triangle"></i> Ошибка</h1>
|
| 835 |
<p class="not-found">Заказ с таким ID не найден.</p>
|
|
|
|
| 839 |
</body>
|
| 840 |
</html>
|
| 841 |
'''
|
|
|
|
| 842 |
ADMIN_TEMPLATE = '''
|
| 843 |
<!DOCTYPE html>
|
| 844 |
<html lang="ru">
|
| 845 |
<head>
|
| 846 |
<meta charset="UTF-8">
|
| 847 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 848 |
+
<title>Админ-панель - Meka Shop</title>
|
| 849 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 850 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 851 |
<style>
|
| 852 |
+
body { font-family: 'Poppins', sans-serif; background-color: #c1e7cc; color: #1a362d; padding: 20px; line-height: 1.6; }
|
| 853 |
.container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
|
| 854 |
+
.header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #9cdbb0; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
|
| 855 |
+
h1, h2, h3 { font-weight: 600; color: #4CAF50; margin-bottom: 15px; }
|
| 856 |
h1 { font-size: 1.8rem; }
|
| 857 |
h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
|
| 858 |
+
h3 { font-size: 1.2rem; color: #388E3C; margin-top: 20px; }
|
| 859 |
+
.section { margin-bottom: 30px; padding: 20px; background-color: #d7f7e0; border: 1px solid #c1e7cc; border-radius: 8px; }
|
| 860 |
form { margin-bottom: 20px; }
|
| 861 |
+
label { font-weight: 500; margin-top: 10px; display: block; color: #4f6a5a; font-size: 0.9rem;}
|
| 862 |
+
input[type="text"], input[type="number"], input[type="password"], input[type="tel"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #9cdbb0; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; background-color: #fff; }
|
| 863 |
+
input:focus, textarea:focus, select:focus { border-color: #4CAF50; outline: none; box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); }
|
| 864 |
textarea { min-height: 80px; resize: vertical; }
|
| 865 |
+
input[type="file"] { padding: 8px; background-color: #d7f7e0; cursor: pointer; border: 1px solid #9cdbb0;}
|
| 866 |
+
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #c1e7cc; border: 1px solid #9cdbb0; cursor: pointer; margin-right: 10px;}
|
| 867 |
input[type="checkbox"] { margin-right: 5px; vertical-align: middle; }
|
| 868 |
label.inline-label { display: inline-block; margin-top: 10px; font-weight: normal; }
|
| 869 |
+
button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #4CAF50; 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;}
|
| 870 |
+
button:hover, .button:hover { background-color: #388E3C; }
|
| 871 |
button:active, .button:active { transform: scale(0.98); }
|
| 872 |
button[type="submit"] { min-width: 120px; justify-content: center; }
|
| 873 |
.delete-button { background-color: #f56565; }
|
| 874 |
.delete-button:hover { background-color: #e53e3e; }
|
| 875 |
+
.add-button { background-color: #4CAF50; }
|
| 876 |
+
.add-button:hover { background-color: #388E3C; }
|
| 877 |
.item-list { display: grid; gap: 20px; }
|
| 878 |
+
.item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #c1e7cc; }
|
| 879 |
+
.item p { margin: 5px 0; font-size: 0.9rem; color: #4f6a5a; }
|
| 880 |
+
.item strong { color: #1a362d; }
|
| 881 |
.item .description { font-size: 0.85rem; color: #a0aec0; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
|
| 882 |
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
| 883 |
+
.item-actions button:not(.delete-button) { background-color: #4CAF50; }
|
| 884 |
+
.item-actions button:not(.delete-button):hover { background-color: #388E3C; }
|
| 885 |
+
.edit-form-container { margin-top: 15px; padding: 20px; background: #d7f7e0; border: 1px dashed #9cdbb0; border-radius: 6px; display: none; }
|
| 886 |
+
details { background-color: #d7f7e0; border: 1px solid #c1e7cc; border-radius: 8px; margin-bottom: 20px; }
|
| 887 |
+
details > summary { cursor: pointer; font-weight: 600; color: #388E3C; display: block; padding: 15px; border-bottom: 1px solid #9cdbb0; list-style: none; position: relative; }
|
| 888 |
+
details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: #4CAF50; }
|
| 889 |
details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
|
| 890 |
+
details[open] > summary { border-bottom: 1px solid #9cdbb0; }
|
| 891 |
details .form-content { padding: 20px; }
|
| 892 |
.color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
| 893 |
.color-input-group input { flex-grow: 1; margin: 0; }
|
| 894 |
.remove-color-btn { background-color: #f56565; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; }
|
| 895 |
.remove-color-btn:hover { background-color: #e53e3e; }
|
| 896 |
+
.add-color-btn { background-color: #a7f3d0; color: #388E3C; }
|
| 897 |
+
.add-color-btn:hover { background-color: #c1e7cc; }
|
| 898 |
+
.photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #9cdbb0; object-fit: cover;}
|
| 899 |
.sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
|
| 900 |
+
.download-hf-button { background-color: #a0aec0; }
|
| 901 |
.download-hf-button:hover { background-color: #718096; }
|
| 902 |
.flex-container { display: flex; flex-wrap: wrap; gap: 20px; }
|
| 903 |
.flex-item { flex: 1; min-width: 350px; }
|
| 904 |
.message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;}
|
| 905 |
+
.message.success { background-color: #f0fff4; color: #155724; border: 1px solid #c3e6cb;}
|
| 906 |
+
.message.error { background-color: #fff5f5; color: #721c24; border: 1px solid #f5c6cb;}
|
| 907 |
+
.message.warning { background-color: #fffaf0; color: #856404; border: 1px solid #ffeeba; }
|
| 908 |
.status-indicator { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 500; margin-left: 10px; vertical-align: middle; }
|
| 909 |
+
.status-indicator.in-stock { background-color: #c6f6d5; color: #2f855a; }
|
| 910 |
+
.status-indicator.out-of-stock { background-color: #fed7d7; color: #c53030; }
|
| 911 |
+
.status-indicator.top-product { background-color: #feebc8; color: #9c4221; margin-left: 5px;}
|
| 912 |
</style>
|
| 913 |
</head>
|
| 914 |
<body>
|
| 915 |
<div class="container">
|
| 916 |
<div class="header">
|
| 917 |
<div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;">
|
| 918 |
+
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Apple-logo.png/2560px-Apple-logo.png" alt="Meka Shop Logo" style="height: 40px; width: auto; border-radius: 4px;">
|
| 919 |
+
<h1><i class="fas fa-tools"></i> Админ-панель Meka Shop</h1>
|
| 920 |
</div>
|
| 921 |
+
<a href="{{ url_for('catalog') }}" class="button" style="background-color: #388E3C;"><i class="fas fa-store"></i> Перейти в каталог</a>
|
| 922 |
</div>
|
|
|
|
|
|
|
| 923 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 924 |
{% if messages %}
|
| 925 |
{% for category, message in messages %}
|
|
|
|
| 927 |
{% endfor %}
|
| 928 |
{% endif %}
|
| 929 |
{% endwith %}
|
|
|
|
| 930 |
<div class="section">
|
| 931 |
<h2><i class="fas fa-sync-alt"></i> Синхронизация с Датацентром</h2>
|
| 932 |
<div class="sync-buttons">
|
|
|
|
| 937 |
<button type="submit" class="button download-hf-button" title="Скачать файлы (перезапишет локальные)"><i class="fas fa-download"></i> Скачать БД</button>
|
| 938 |
</form>
|
| 939 |
</div>
|
| 940 |
+
<p style="font-size: 0.85rem; color: #4f6a5a;">Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
|
| 941 |
</div>
|
|
|
|
| 942 |
<div class="flex-container">
|
| 943 |
<div class="flex-item">
|
| 944 |
<div class="section">
|
|
|
|
| 954 |
</form>
|
| 955 |
</div>
|
| 956 |
</details>
|
|
|
|
| 957 |
<h3>Существующие категории:</h3>
|
| 958 |
{% if categories %}
|
| 959 |
<div class="item-list">
|
|
|
|
| 973 |
{% endif %}
|
| 974 |
</div>
|
| 975 |
</div>
|
|
|
|
| 976 |
<div class="flex-item">
|
| 977 |
<div class="section">
|
| 978 |
<h2><i class="fas fa-info-circle"></i> Информация</h2>
|
|
|
|
| 981 |
</div>
|
| 982 |
</div>
|
| 983 |
</div>
|
|
|
|
| 984 |
<div class="section">
|
| 985 |
<h2><i class="fas fa-box-open"></i> Управление товарами</h2>
|
| 986 |
<details>
|
|
|
|
| 1025 |
</form>
|
| 1026 |
</div>
|
| 1027 |
</details>
|
|
|
|
| 1028 |
<h3>Список товаров:</h3>
|
| 1029 |
{% if products %}
|
| 1030 |
<div class="item-list">
|
|
|
|
| 1041 |
{% endif %}
|
| 1042 |
</div>
|
| 1043 |
<div style="flex-grow: 1;">
|
| 1044 |
+
<h3 style="margin-top: 0; margin-bottom: 5px; color: #1a362d;">
|
| 1045 |
{{ product['name'] }}
|
| 1046 |
{% if product.get('in_stock', True) %}
|
| 1047 |
<span class="status-indicator in-stock">В наличии</span>
|
|
|
|
| 1058 |
{% set colors = product.get('colors', []) %}
|
| 1059 |
<p><strong>Цвета/Вар-ты:</strong> {{ colors|select('ne', '')|join(', ') if colors|select('ne', '')|list|length > 0 else 'Нет' }}</p>
|
| 1060 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 1061 |
+
<p style="font-size: 0.8rem; color: #4f6a5a;">(Всего фото: {{ product['photos']|length }})</p>
|
| 1062 |
{% endif %}
|
| 1063 |
</div>
|
| 1064 |
</div>
|
|
|
|
| 1065 |
<div class="item-actions">
|
| 1066 |
<button type="button" class="button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
|
| 1067 |
<form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
|
|
|
|
| 1070 |
<button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
|
| 1071 |
</form>
|
| 1072 |
</div>
|
|
|
|
| 1073 |
<div id="edit-form-{{ loop.index0 }}" class="edit-form-container">
|
| 1074 |
<h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
|
| 1075 |
<form method="POST" enctype="multipart/form-data">
|
|
|
|
| 1112 |
{% endfor %}
|
| 1113 |
{% else %}
|
| 1114 |
<div class="color-input-group">
|
| 1115 |
+
<input type="text" name="colors" placeholder="Например: Цвет">
|
| 1116 |
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1117 |
</div>
|
| 1118 |
{% endif %}
|
|
|
|
| 1138 |
<p>Товаров пока нет.</p>
|
| 1139 |
{% endif %}
|
| 1140 |
</div>
|
|
|
|
| 1141 |
</div>
|
|
|
|
| 1142 |
<script>
|
| 1143 |
function toggleEditForm(formId) {
|
| 1144 |
const formContainer = document.getElementById(formId);
|
|
|
|
| 1146 |
formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none';
|
| 1147 |
}
|
| 1148 |
}
|
|
|
|
| 1149 |
function addColorInput(containerId) {
|
| 1150 |
const container = document.getElementById(containerId);
|
| 1151 |
if (container) {
|
|
|
|
| 1162 |
}
|
| 1163 |
}
|
| 1164 |
}
|
|
|
|
| 1165 |
function removeColorInput(button) {
|
| 1166 |
const group = button.closest('.color-input-group');
|
| 1167 |
if (group) {
|
| 1168 |
const container = group.parentNode;
|
| 1169 |
group.remove();
|
|
|
|
| 1170 |
if (container && container.children.length === 0) {
|
| 1171 |
const placeholderGroup = document.createElement('div');
|
| 1172 |
placeholderGroup.className = 'color-input-group';
|
|
|
|
| 1184 |
</body>
|
| 1185 |
</html>
|
| 1186 |
'''
|
|
|
|
|
|
|
|
|
|
| 1187 |
@app.route('/')
|
| 1188 |
def catalog():
|
| 1189 |
data = load_data()
|
| 1190 |
all_products = data.get('products', [])
|
| 1191 |
categories = sorted(data.get('categories', []))
|
|
|
|
| 1192 |
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 1193 |
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
|
|
|
| 1194 |
return render_template_string(
|
| 1195 |
CATALOG_TEMPLATE,
|
| 1196 |
products=products_sorted,
|
|
|
|
| 1199 |
store_address=STORE_ADDRESS,
|
| 1200 |
currency_code=CURRENCY_CODE
|
| 1201 |
)
|
|
|
|
| 1202 |
@app.route('/product/<int:index>')
|
| 1203 |
def product_detail(index):
|
| 1204 |
data = load_data()
|
| 1205 |
all_products = data.get('products', [])
|
| 1206 |
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 1207 |
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
|
|
|
| 1208 |
try:
|
| 1209 |
product = products_sorted[index]
|
| 1210 |
except IndexError:
|
| 1211 |
logging.warning(f"Attempted access to non-existent or out-of-stock product with index {index}")
|
| 1212 |
return "Товар не найден или отсутствует в наличии.", 404
|
|
|
|
| 1213 |
return render_template_string(
|
| 1214 |
PRODUCT_DETAIL_TEMPLATE,
|
| 1215 |
product=product,
|
| 1216 |
repo_id=REPO_ID,
|
| 1217 |
currency_code=CURRENCY_CODE
|
| 1218 |
)
|
|
|
|
| 1219 |
@app.route('/create_order', methods=['POST'])
|
| 1220 |
def create_order():
|
| 1221 |
order_data = request.get_json()
|
|
|
|
| 1222 |
if not order_data or 'cart' not in order_data or not order_data['cart']:
|
| 1223 |
logging.warning("Create order request missing cart data or cart is empty.")
|
| 1224 |
return jsonify({"error": "Корзина пуста или не передана."}), 400
|
|
|
|
| 1225 |
cart_items = order_data['cart']
|
|
|
|
| 1226 |
total_price = 0
|
| 1227 |
processed_cart = []
|
| 1228 |
for item in cart_items:
|
|
|
|
| 1246 |
except (ValueError, TypeError) as e:
|
| 1247 |
logging.error(f"Invalid price/quantity in cart item: {item}. Error: {e}")
|
| 1248 |
return jsonify({"error": "Неверная цена или количество в товаре."}), 400
|
|
|
|
| 1249 |
order_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
| 1250 |
order_timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
| 1251 |
new_order = {
|
| 1252 |
"id": order_id,
|
| 1253 |
"created_at": order_timestamp,
|
| 1254 |
"cart": processed_cart,
|
| 1255 |
"total_price": round(total_price, 2),
|
| 1256 |
+
"user_info": None,
|
| 1257 |
"status": "new"
|
| 1258 |
}
|
|
|
|
| 1259 |
try:
|
| 1260 |
data = load_data()
|
| 1261 |
if 'orders' not in data or not isinstance(data.get('orders'), dict):
|
| 1262 |
data['orders'] = {}
|
|
|
|
| 1263 |
data['orders'][order_id] = new_order
|
| 1264 |
save_data(data)
|
| 1265 |
logging.info(f"Order {order_id} created successfully (anonymously).")
|
| 1266 |
return jsonify({"order_id": order_id}), 201
|
|
|
|
| 1267 |
except Exception as e:
|
| 1268 |
logging.error(f"Failed to save order {order_id}: {e}", exc_info=True)
|
| 1269 |
return jsonify({"error": "Ошибка сервера при сохранении заказа."}), 500
|
|
|
|
| 1270 |
@app.route('/order/<order_id>')
|
| 1271 |
def view_order(order_id):
|
| 1272 |
data = load_data()
|
| 1273 |
order = data.get('orders', {}).get(order_id)
|
|
|
|
| 1274 |
if order:
|
| 1275 |
logging.info(f"Displaying order {order_id}")
|
| 1276 |
else:
|
| 1277 |
logging.warning(f"Order {order_id} not found.")
|
|
|
|
| 1278 |
return render_template_string(ORDER_TEMPLATE,
|
| 1279 |
order=order,
|
| 1280 |
repo_id=REPO_ID,
|
| 1281 |
+
currency_code=CURRENCY_CODE,
|
| 1282 |
+
whatsapp_number=WHATSAPP_NUMBER)
|
| 1283 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1284 |
def admin():
|
| 1285 |
data = load_data()
|
|
|
|
| 1287 |
categories = data.get('categories', [])
|
| 1288 |
if 'orders' not in data or not isinstance(data.get('orders'), dict):
|
| 1289 |
data['orders'] = {}
|
|
|
|
| 1290 |
if request.method == 'POST':
|
| 1291 |
action = request.form.get('action')
|
| 1292 |
logging.info(f"Admin action received: {action}")
|
|
|
|
| 1293 |
try:
|
| 1294 |
if action == 'add_category':
|
| 1295 |
category_name = request.form.get('category_name', '').strip()
|
|
|
|
| 1305 |
else:
|
| 1306 |
logging.warning(f"Category '{category_name}' already exists.")
|
| 1307 |
flash(f"Категория '{category_name}' уже существует.", 'error')
|
|
|
|
| 1308 |
elif action == 'delete_category':
|
| 1309 |
category_to_delete = request.form.get('category_name')
|
| 1310 |
if category_to_delete and category_to_delete in categories:
|
|
|
|
| 1322 |
else:
|
| 1323 |
logging.warning(f"Attempted to delete non-existent or empty category: {category_to_delete}")
|
| 1324 |
flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error')
|
|
|
|
| 1325 |
elif action == 'add_product':
|
| 1326 |
name = request.form.get('name', '').strip()
|
| 1327 |
price_str = request.form.get('price', '').replace(',', '.')
|
|
|
|
| 1331 |
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1332 |
in_stock = 'in_stock' in request.form
|
| 1333 |
is_top = 'is_top' in request.form
|
|
|
|
| 1334 |
if not name or not price_str:
|
| 1335 |
flash("Название и цена товара обязательны.", 'error')
|
| 1336 |
return redirect(url_for('admin'))
|
|
|
|
| 1337 |
try:
|
| 1338 |
price = round(float(price_str), 2)
|
| 1339 |
if price < 0: price = 0
|
| 1340 |
except ValueError:
|
| 1341 |
flash("Неверный формат цены.", 'error')
|
| 1342 |
return redirect(url_for('admin'))
|
|
|
|
| 1343 |
photos_list = []
|
| 1344 |
if photos_files and HF_TOKEN_WRITE:
|
| 1345 |
uploads_dir = 'uploads_temp'
|
|
|
|
| 1359 |
logging.warning(f"Skipping non-image file upload: {photo.filename}")
|
| 1360 |
flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning")
|
| 1361 |
continue
|
|
|
|
| 1362 |
safe_name = secure_filename(name.replace(' ', '_'))[:50]
|
| 1363 |
photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1364 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
|
|
|
| 1391 |
logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}")
|
| 1392 |
elif not HF_TOKEN_WRITE and photos_files and any(f.filename for f in photos_files):
|
| 1393 |
flash("HF_TOKEN (write) не настроен. Фотографии не были загружены.", "warning")
|
|
|
|
|
|
|
| 1394 |
new_product = {
|
| 1395 |
'name': name, 'price': price, 'description': description,
|
| 1396 |
'category': category if category in categories else 'Без категории',
|
|
|
|
| 1402 |
save_data(data)
|
| 1403 |
logging.info(f"Product '{name}' added.")
|
| 1404 |
flash(f"Товар '{name}' успешно добавлен.", 'success')
|
|
|
|
| 1405 |
elif action == 'edit_product':
|
| 1406 |
index_str = request.form.get('index')
|
| 1407 |
if index_str is None:
|
| 1408 |
flash("Ошибка редактирования: индекс товара не передан.", 'error')
|
| 1409 |
return redirect(url_for('admin'))
|
|
|
|
| 1410 |
try:
|
| 1411 |
index = int(index_str)
|
| 1412 |
if not (0 <= index < len(products)):
|
| 1413 |
raise IndexError("Product index out of range")
|
| 1414 |
product_to_edit = products[index]
|
| 1415 |
original_name = product_to_edit.get('name', 'N/A')
|
|
|
|
| 1416 |
except (ValueError, IndexError):
|
| 1417 |
flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
|
| 1418 |
logging.error(f"Invalid index '{index_str}' for editing. Product list length: {len(products)}")
|
| 1419 |
return redirect(url_for('admin'))
|
|
|
|
| 1420 |
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
|
| 1421 |
price_str = request.form.get('price', str(product_to_edit['price'])).replace(',', '.')
|
| 1422 |
product_to_edit['description'] = request.form.get('description', product_to_edit.get('description', '')).strip()
|
|
|
|
| 1425 |
product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1426 |
product_to_edit['in_stock'] = 'in_stock' in request.form
|
| 1427 |
product_to_edit['is_top'] = 'is_top' in request.form
|
|
|
|
| 1428 |
try:
|
| 1429 |
price = round(float(price_str), 2)
|
| 1430 |
if price < 0: price = 0
|
|
|
|
| 1432 |
except ValueError:
|
| 1433 |
logging.warning(f"Invalid price format '{price_str}' during edit of product {original_name}. Price not changed.")
|
| 1434 |
flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
|
|
|
|
| 1435 |
photos_files = request.files.getlist('photos')
|
| 1436 |
if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
|
| 1437 |
uploads_dir = 'uploads_temp'
|
|
|
|
| 1453 |
logging.warning(f"Skipping non-image file upload during edit: {photo.filename}")
|
| 1454 |
flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning")
|
| 1455 |
continue
|
|
|
|
| 1456 |
safe_name = secure_filename(product_to_edit['name'].replace(' ', '_'))[:50]
|
| 1457 |
photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1458 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
|
|
|
| 1476 |
os.rmdir(uploads_dir)
|
| 1477 |
except OSError as e:
|
| 1478 |
logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}")
|
|
|
|
| 1479 |
if new_photos_list:
|
| 1480 |
logging.info(f"New photo list for product {product_to_edit['name']} generated.")
|
| 1481 |
old_photos = product_to_edit.get('photos', [])
|
|
|
|
| 1499 |
flash("Не удалось загрузить новые фотографии (возможно, неверный формат).", "error")
|
| 1500 |
elif not HF_TOKEN_WRITE and photos_files and any(f.filename for f in photos_files):
|
| 1501 |
flash("HF_TOKEN (write) не настроен. Фотографии не были обновлены.", "warning")
|
|
|
|
| 1502 |
products[index] = product_to_edit
|
| 1503 |
data['products'] = products
|
| 1504 |
save_data(data)
|
| 1505 |
logging.info(f"Product '{original_name}' (index {index}) updated to '{product_to_edit['name']}'.")
|
| 1506 |
flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
|
|
|
|
| 1507 |
elif action == 'delete_product':
|
| 1508 |
index_str = request.form.get('index')
|
| 1509 |
if index_str is None:
|
|
|
|
| 1514 |
if not (0 <= index < len(products)): raise IndexError("Product index out of range")
|
| 1515 |
deleted_product = products.pop(index)
|
| 1516 |
product_name = deleted_product.get('name', 'N/A')
|
|
|
|
| 1517 |
photos_to_delete = deleted_product.get('photos', [])
|
| 1518 |
if photos_to_delete and HF_TOKEN_WRITE:
|
| 1519 |
logging.info(f"Attempting to delete photos for product '{product_name}' from HF: {photos_to_delete}")
|
|
|
|
| 1533 |
elif photos_to_delete and not HF_TOKEN_WRITE:
|
| 1534 |
logging.warning(f"HF_TOKEN (write) not set. Cannot delete photos {photos_to_delete} for deleted product '{product_name}'.")
|
| 1535 |
flash(f"Товар '{product_name}' удален локально, но фото не удалены с сервера (токен не задан).", "warning")
|
|
|
|
|
|
|
| 1536 |
data['products'] = products
|
| 1537 |
save_data(data)
|
| 1538 |
logging.info(f"Product '{product_name}' (original index {index}) deleted.")
|
|
|
|
| 1540 |
except (ValueError, IndexError):
|
| 1541 |
flash(f"Ошибка удаления: неверный индекс товара '{index_str}'.", 'error')
|
| 1542 |
logging.error(f"Invalid index '{index_str}' for deletion. Product list length: {len(products)}")
|
|
|
|
| 1543 |
else:
|
| 1544 |
logging.warning(f"Received unknown admin action: {action}")
|
| 1545 |
flash(f"Неизвестное действие: {action}", 'warning')
|
|
|
|
| 1546 |
return redirect(url_for('admin'))
|
|
|
|
| 1547 |
except Exception as e:
|
| 1548 |
logging.error(f"Error processing admin action '{action}': {e}", exc_info=True)
|
| 1549 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1550 |
return redirect(url_for('admin'))
|
|
|
|
|
|
|
| 1551 |
current_data = load_data()
|
| 1552 |
display_products = sorted(current_data.get('products', []), key=lambda p: p.get('name', '').lower())
|
| 1553 |
display_categories = sorted(current_data.get('categories', []))
|
|
|
|
| 1554 |
return render_template_string(
|
| 1555 |
ADMIN_TEMPLATE,
|
| 1556 |
products=display_products,
|
|
|
|
| 1558 |
repo_id=REPO_ID,
|
| 1559 |
currency_code=CURRENCY_CODE
|
| 1560 |
)
|
|
|
|
| 1561 |
@app.route('/force_upload', methods=['POST'])
|
| 1562 |
def force_upload():
|
| 1563 |
logging.info("Forcing upload to Hugging Face...")
|
|
|
|
| 1568 |
logging.error(f"Error during forced upload: {e}", exc_info=True)
|
| 1569 |
flash(f"Ошибка при загрузке на Hugging Face: {e}", 'error')
|
| 1570 |
return redirect(url_for('admin'))
|
|
|
|
| 1571 |
@app.route('/force_download', methods=['POST'])
|
| 1572 |
def force_download():
|
| 1573 |
logging.info("Forcing download from Hugging Face...")
|
| 1574 |
try:
|
| 1575 |
if download_db_from_hf():
|
| 1576 |
flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success')
|
| 1577 |
+
load_data()
|
| 1578 |
else:
|
| 1579 |
flash("Не удалось скачать данные с Hugging Face после нескольких попыток. Проверьте логи.", 'error')
|
| 1580 |
except Exception as e:
|
| 1581 |
logging.error(f"Error during forced download: {e}", exc_info=True)
|
| 1582 |
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
|
| 1583 |
return redirect(url_for('admin'))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1584 |
if __name__ == '__main__':
|
| 1585 |
logging.info("Application starting up. Performing initial data load/download...")
|
| 1586 |
download_db_from_hf()
|
| 1587 |
load_data()
|
| 1588 |
logging.info("Initial data load complete.")
|
|
|
|
| 1589 |
if HF_TOKEN_WRITE:
|
| 1590 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 1591 |
backup_thread.start()
|
| 1592 |
logging.info("Periodic backup thread started.")
|
| 1593 |
else:
|
| 1594 |
logging.warning("Periodic backup will NOT run (HF_TOKEN for writing not set).")
|
|
|
|
| 1595 |
port = int(os.environ.get('PORT', 7860))
|
| 1596 |
logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
|
| 1597 |
app.run(debug=False, host='0.0.0.0', port=port)
|
|
|
|
|
|