Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -12,28 +12,39 @@ from werkzeug.utils import secure_filename
|
|
| 12 |
from dotenv import load_dotenv
|
| 13 |
import requests
|
| 14 |
import uuid
|
|
|
|
| 15 |
load_dotenv()
|
|
|
|
| 16 |
app = Flask(__name__)
|
| 17 |
-
app.secret_key = '
|
| 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 |
-
|
| 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,13 +85,17 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
|
|
| 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,6 +104,7 @@ def upload_db_to_hf(specific_file=None):
|
|
| 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,6 +124,7 @@ def upload_db_to_hf(specific_file=None):
|
|
| 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,6 +133,7 @@ def periodic_backup():
|
|
| 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,6 +151,7 @@ def load_data():
|
|
| 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,6 +183,8 @@ def load_data():
|
|
| 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,12 +193,15 @@ def save_data(data):
|
|
| 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">
|
|
@@ -190,89 +214,67 @@ CATALOG_TEMPLATE = '''
|
|
| 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: #
|
| 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 #
|
| 197 |
-
|
| 198 |
-
.
|
| 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 #
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 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 |
-
|
| 219 |
-
|
| 220 |
-
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.
|
| 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: #
|
| 226 |
-
|
| 227 |
-
.product-
|
| 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: #
|
| 233 |
-
.product-button:hover { background-color: #
|
| 234 |
.product-button i { margin-right: 5px; }
|
| 235 |
-
.add-to-cart { background-color: #
|
| 236 |
-
.add-to-cart:hover { background-color: #
|
| 237 |
-
#cart-button { position: fixed; bottom: 25px; right: 25px; background-color: #
|
|
|
|
| 238 |
#cart-button .fa-shopping-cart { margin-right: 0; }
|
| 239 |
-
#cart-button span { position: absolute; top: -5px; right: -5px; background-color: #
|
| 240 |
-
.modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.
|
| 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.
|
| 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: #
|
| 246 |
-
|
| 247 |
-
|
| 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: #
|
| 257 |
-
|
| 258 |
-
.cart-item-
|
| 259 |
-
.cart-item-remove {
|
| 260 |
-
.
|
| 261 |
-
.quantity-input, .color-select {
|
| 262 |
-
|
| 263 |
-
.cart-summary {
|
| 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: #
|
| 269 |
-
.clear-cart:hover { background-color: #
|
| 270 |
-
.formulate-order-button { background-color: #
|
| 271 |
-
.formulate-order-button:hover { background-color: #
|
| 272 |
-
.notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: #
|
| 273 |
.notification.show { opacity: 1;}
|
| 274 |
-
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #
|
| 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,23 +283,23 @@ CATALOG_TEMPLATE = '''
|
|
| 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,12 +336,14 @@ CATALOG_TEMPLATE = '''
|
|
| 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,6 +355,7 @@ CATALOG_TEMPLATE = '''
|
|
| 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,34 +374,23 @@ CATALOG_TEMPLATE = '''
|
|
| 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 }};
|
| 380 |
const repoId = '{{ repo_id }}';
|
| 381 |
const currencyCode = '{{ currency_code }}';
|
| 382 |
let selectedProductIndex = null;
|
| 383 |
-
let cart = JSON.parse(localStorage.getItem('
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
const icon = document.querySelector('.theme-toggle i');
|
| 387 |
-
const isDarkMode = document.body.classList.contains('dark-mode');
|
| 388 |
-
icon.classList.toggle('fa-moon', !isDarkMode);
|
| 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') {
|
| 395 |
-
document.body.classList.add('dark-mode');
|
| 396 |
-
const icon = document.querySelector('.theme-toggle i');
|
| 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,6 +399,7 @@ CATALOG_TEMPLATE = '''
|
|
| 405 |
document.body.style.overflow = 'hidden';
|
| 406 |
}
|
| 407 |
}
|
|
|
|
| 408 |
function closeModal(modalId) {
|
| 409 |
const modal = document.getElementById(modalId);
|
| 410 |
if (modal) {
|
|
@@ -415,6 +410,7 @@ CATALOG_TEMPLATE = '''
|
|
| 415 |
document.body.style.overflow = 'auto';
|
| 416 |
}
|
| 417 |
}
|
|
|
|
| 418 |
function loadProductDetails(index) {
|
| 419 |
const modalContent = document.getElementById('modalContent');
|
| 420 |
if (!modalContent) return;
|
|
@@ -430,9 +426,10 @@ CATALOG_TEMPLATE = '''
|
|
| 430 |
})
|
| 431 |
.catch(error => {
|
| 432 |
console.error('Ошибка загрузки деталей продукта:', error);
|
| 433 |
-
modalContent.innerHTML = `<p style="color:
|
| 434 |
});
|
| 435 |
}
|
|
|
|
| 436 |
function initializeSwiper() {
|
| 437 |
const swiperContainer = document.querySelector('#productModal .swiper-container');
|
| 438 |
if (swiperContainer) {
|
|
@@ -448,6 +445,7 @@ CATALOG_TEMPLATE = '''
|
|
| 448 |
});
|
| 449 |
}
|
| 450 |
}
|
|
|
|
| 451 |
function openQuantityModal(index) {
|
| 452 |
selectedProductIndex = index;
|
| 453 |
const product = products[index];
|
|
@@ -456,10 +454,13 @@ CATALOG_TEMPLATE = '''
|
|
| 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,6 +474,7 @@ CATALOG_TEMPLATE = '''
|
|
| 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,24 +482,30 @@ CATALOG_TEMPLATE = '''
|
|
| 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,17 +518,21 @@ CATALOG_TEMPLATE = '''
|
|
| 510 |
color: color
|
| 511 |
});
|
| 512 |
}
|
| 513 |
-
|
|
|
|
| 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,11 +541,14 @@ CATALOG_TEMPLATE = '''
|
|
| 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,6 +560,7 @@ CATALOG_TEMPLATE = '''
|
|
| 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,31 +581,38 @@ CATALOG_TEMPLATE = '''
|
|
| 565 |
document.body.style.overflow = 'hidden';
|
| 566 |
}
|
| 567 |
}
|
|
|
|
| 568 |
function removeFromCart(itemId) {
|
| 569 |
cart = cart.filter(item => item.id !== itemId);
|
| 570 |
-
localStorage.setItem('
|
| 571 |
openCartModal();
|
| 572 |
updateCartButton();
|
| 573 |
}
|
|
|
|
| 574 |
function clearCart() {
|
| 575 |
if (confirm("Вы уверены, что хотите очистить корзину?")) {
|
| 576 |
cart = [];
|
| 577 |
-
localStorage.removeItem('
|
| 578 |
openCartModal();
|
| 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' },
|
|
@@ -603,7 +626,7 @@ CATALOG_TEMPLATE = '''
|
|
| 603 |
})
|
| 604 |
.then(data => {
|
| 605 |
if (data.order_id) {
|
| 606 |
-
localStorage.removeItem('
|
| 607 |
cart = [];
|
| 608 |
updateCartButton();
|
| 609 |
closeModal('cartModal');
|
|
@@ -618,20 +641,26 @@ CATALOG_TEMPLATE = '''
|
|
| 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,6 +668,7 @@ CATALOG_TEMPLATE = '''
|
|
| 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,10 +681,13 @@ CATALOG_TEMPLATE = '''
|
|
| 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,6 +697,7 @@ CATALOG_TEMPLATE = '''
|
|
| 664 |
});
|
| 665 |
filterProducts();
|
| 666 |
}
|
|
|
|
| 667 |
function showNotification(message, duration = 3000) {
|
| 668 |
const placeholder = document.getElementById('notification-placeholder');
|
| 669 |
if (!placeholder) {
|
|
@@ -677,26 +711,33 @@ CATALOG_TEMPLATE = '''
|
|
| 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,14 +746,16 @@ CATALOG_TEMPLATE = '''
|
|
| 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: #
|
| 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 %}
|
| 718 |
{% for photo in product['photos'] %}
|
|
@@ -732,13 +775,14 @@ PRODUCT_DETAIL_TEMPLATE = '''
|
|
| 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: #
|
| 736 |
-
<div class="swiper-button-prev" style="color: #
|
| 737 |
{% endif %}
|
| 738 |
</div>
|
| 739 |
-
|
|
|
|
| 740 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 741 |
-
<p style="font-size: 1.2rem; font-weight: bold; color: #
|
| 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,6 +791,7 @@ PRODUCT_DETAIL_TEMPLATE = '''
|
|
| 747 |
</div>
|
| 748 |
</div>
|
| 749 |
'''
|
|
|
|
| 750 |
ORDER_TEMPLATE = '''
|
| 751 |
<!DOCTYPE html>
|
| 752 |
<html lang="ru">
|
|
@@ -757,31 +802,31 @@ ORDER_TEMPLATE = '''
|
|
| 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: #
|
| 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.
|
| 762 |
-
h1 { text-align: center; color: #
|
| 763 |
-
h2 { color: #
|
| 764 |
-
.order-meta { font-size: 0.9rem; color: #
|
| 765 |
-
.order-item { display: grid; grid-template-columns: 60px 1fr auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #
|
| 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 #
|
| 768 |
-
.item-details strong { display: block; margin-bottom: 5px; font-size: 1.05rem; color: #
|
| 769 |
-
.item-details span { font-size: 0.9rem; color: #
|
| 770 |
-
.item-total { font-weight: bold; text-align: right; font-size: 1rem; color: #
|
| 771 |
-
.order-summary { margin-top: 30px; padding-top: 20px; border-top: 2px solid #
|
| 772 |
.order-summary p { margin-bottom: 10px; font-size: 1.1rem; }
|
| 773 |
-
.order-summary strong { font-size: 1.3rem; color: #
|
| 774 |
-
.customer-info { margin-top: 30px; background-color: #
|
| 775 |
.customer-info p { margin-bottom: 8px; font-size: 0.95rem; }
|
| 776 |
-
.customer-info strong { color: #
|
| 777 |
.actions { margin-top: 30px; text-align: center; }
|
| 778 |
-
.button { padding: 12px 25px; border: none; border-radius: 8px; background-color: #
|
| 779 |
-
.button:hover { background-color: #
|
| 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: #
|
| 783 |
.catalog-link:hover { text-decoration: underline; }
|
| 784 |
-
.not-found { text-align: center; color: #
|
| 785 |
</style>
|
| 786 |
</head>
|
| 787 |
<body>
|
|
@@ -789,6 +834,7 @@ ORDER_TEMPLATE = '''
|
|
| 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,34 +850,42 @@ ORDER_TEMPLATE = '''
|
|
| 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 = "
|
|
|
|
| 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: #
|
| 835 |
<p class="not-found">Заказ с таким ID не найден.</p>
|
| 836 |
<a href="{{ url_for('catalog') }}" class="catalog-link">← Вернуться в каталог</a>
|
| 837 |
{% endif %}
|
|
@@ -839,6 +893,7 @@ ORDER_TEMPLATE = '''
|
|
| 839 |
</body>
|
| 840 |
</html>
|
| 841 |
'''
|
|
|
|
| 842 |
ADMIN_TEMPLATE = '''
|
| 843 |
<!DOCTYPE html>
|
| 844 |
<html lang="ru">
|
|
@@ -849,77 +904,77 @@ ADMIN_TEMPLATE = '''
|
|
| 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: #
|
| 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 #
|
| 855 |
-
h1, h2, h3 { font-weight: 600; color: #
|
| 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: #
|
| 859 |
-
.section { margin-bottom: 30px; padding: 20px; background-color: #
|
| 860 |
form { margin-bottom: 20px; }
|
| 861 |
-
label { font-weight: 500; margin-top: 10px; display: block; color: #
|
| 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 #
|
| 863 |
-
input:focus, textarea:focus, select:focus { border-color: #
|
| 864 |
textarea { min-height: 80px; resize: vertical; }
|
| 865 |
-
input[type="file"] { padding: 8px; background-color: #
|
| 866 |
-
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #
|
| 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: #
|
| 870 |
-
button:hover, .button:hover { background-color: #
|
| 871 |
button:active, .button:active { transform: scale(0.98); }
|
| 872 |
button[type="submit"] { min-width: 120px; justify-content: center; }
|
| 873 |
-
.delete-button { background-color: #
|
| 874 |
-
.delete-button:hover { background-color: #
|
| 875 |
-
.add-button { background-color: #
|
| 876 |
-
.add-button:hover { background-color: #
|
| 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.
|
| 879 |
-
.item p { margin: 5px 0; font-size: 0.9rem; color: #
|
| 880 |
-
.item strong { color: #
|
| 881 |
-
.item .description { font-size: 0.85rem; color: #
|
| 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: #
|
| 884 |
-
.item-actions button:not(.delete-button):hover { background-color: #
|
| 885 |
-
.edit-form-container { margin-top: 15px; padding: 20px; background: #
|
| 886 |
-
details { background-color: #
|
| 887 |
-
details > summary { cursor: pointer; font-weight: 600; color: #
|
| 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: #
|
| 889 |
details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
|
| 890 |
-
details[open] > summary { border-bottom: 1px solid #
|
| 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: #
|
| 895 |
-
.remove-color-btn:hover { background-color: #
|
| 896 |
-
.add-color-btn { background-color: #
|
| 897 |
-
.add-color-btn:hover { background-color: #
|
| 898 |
-
.photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #
|
| 899 |
.sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
|
| 900 |
-
.download-hf-button { background-color: #
|
| 901 |
-
.download-hf-button:hover { background-color: #
|
| 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: #
|
| 906 |
-
.message.error { background-color: #
|
| 907 |
-
.message.warning { background-color: #
|
| 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: #
|
| 910 |
-
.status-indicator.out-of-stock { background-color: #
|
| 911 |
-
.status-indicator.top-product { background-color: #
|
| 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: #
|
| 922 |
</div>
|
|
|
|
| 923 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 924 |
{% if messages %}
|
| 925 |
{% for category, message in messages %}
|
|
@@ -927,6 +982,7 @@ ADMIN_TEMPLATE = '''
|
|
| 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,8 +993,9 @@ ADMIN_TEMPLATE = '''
|
|
| 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: #
|
| 941 |
</div>
|
|
|
|
| 942 |
<div class="flex-container">
|
| 943 |
<div class="flex-item">
|
| 944 |
<div class="section">
|
|
@@ -954,6 +1011,7 @@ ADMIN_TEMPLATE = '''
|
|
| 954 |
</form>
|
| 955 |
</div>
|
| 956 |
</details>
|
|
|
|
| 957 |
<h3>Существующие категории:</h3>
|
| 958 |
{% if categories %}
|
| 959 |
<div class="item-list">
|
|
@@ -973,6 +1031,7 @@ ADMIN_TEMPLATE = '''
|
|
| 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,6 +1040,7 @@ ADMIN_TEMPLATE = '''
|
|
| 981 |
</div>
|
| 982 |
</div>
|
| 983 |
</div>
|
|
|
|
| 984 |
<div class="section">
|
| 985 |
<h2><i class="fas fa-box-open"></i> Управление товарами</h2>
|
| 986 |
<details>
|
|
@@ -1025,6 +1085,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1025 |
</form>
|
| 1026 |
</div>
|
| 1027 |
</details>
|
|
|
|
| 1028 |
<h3>Список товаров:</h3>
|
| 1029 |
{% if products %}
|
| 1030 |
<div class="item-list">
|
|
@@ -1041,7 +1102,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1041 |
{% endif %}
|
| 1042 |
</div>
|
| 1043 |
<div style="flex-grow: 1;">
|
| 1044 |
-
<h3 style="margin-top: 0; margin-bottom: 5px; color: #
|
| 1045 |
{{ product['name'] }}
|
| 1046 |
{% if product.get('in_stock', True) %}
|
| 1047 |
<span class="status-indicator in-stock">В наличии</span>
|
|
@@ -1058,10 +1119,11 @@ ADMIN_TEMPLATE = '''
|
|
| 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: #
|
| 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,6 +1132,7 @@ ADMIN_TEMPLATE = '''
|
|
| 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">
|
|
@@ -1138,7 +1201,9 @@ ADMIN_TEMPLATE = '''
|
|
| 1138 |
<p>Товаров пока нет.</p>
|
| 1139 |
{% endif %}
|
| 1140 |
</div>
|
|
|
|
| 1141 |
</div>
|
|
|
|
| 1142 |
<script>
|
| 1143 |
function toggleEditForm(formId) {
|
| 1144 |
const formContainer = document.getElementById(formId);
|
|
@@ -1146,6 +1211,7 @@ ADMIN_TEMPLATE = '''
|
|
| 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,6 +1228,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1162 |
}
|
| 1163 |
}
|
| 1164 |
}
|
|
|
|
| 1165 |
function removeColorInput(button) {
|
| 1166 |
const group = button.closest('.color-input-group');
|
| 1167 |
if (group) {
|
|
@@ -1184,13 +1251,17 @@ ADMIN_TEMPLATE = '''
|
|
| 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,30 +1270,37 @@ def catalog():
|
|
| 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,8 +1324,10 @@ def create_order():
|
|
| 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,
|
|
@@ -1256,30 +1336,38 @@ def create_order():
|
|
| 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 |
-
|
|
|
|
| 1283 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1284 |
def admin():
|
| 1285 |
data = load_data()
|
|
@@ -1287,9 +1375,11 @@ def admin():
|
|
| 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,6 +1395,7 @@ def admin():
|
|
| 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,6 +1413,7 @@ def admin():
|
|
| 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,15 +1423,18 @@ def admin():
|
|
| 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,6 +1454,7 @@ def admin():
|
|
| 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,6 +1487,8 @@ def admin():
|
|
| 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,21 +1500,25 @@ def admin():
|
|
| 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,6 +1527,7 @@ def admin():
|
|
| 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,6 +1535,7 @@ def admin():
|
|
| 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,6 +1557,7 @@ def admin():
|
|
| 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,6 +1581,7 @@ def admin():
|
|
| 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,11 +1605,13 @@ def admin():
|
|
| 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,6 +1622,7 @@ def admin():
|
|
| 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,6 +1642,8 @@ def admin():
|
|
| 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,17 +1651,22 @@ def admin():
|
|
| 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"
|
| 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,6 +1674,7 @@ def admin():
|
|
| 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,6 +1685,7 @@ def force_upload():
|
|
| 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...")
|
|
@@ -1581,17 +1699,21 @@ def force_download():
|
|
| 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)
|
|
|
|
| 12 |
from dotenv import load_dotenv
|
| 13 |
import requests
|
| 14 |
import uuid
|
| 15 |
+
|
| 16 |
load_dotenv()
|
| 17 |
+
|
| 18 |
app = Flask(__name__)
|
| 19 |
+
app.secret_key = 'your_unique_secret_key_meka_shop_12345_no_login'
|
| 20 |
DATA_FILE = 'data.json'
|
| 21 |
+
|
| 22 |
SYNC_FILES = [DATA_FILE]
|
| 23 |
+
|
| 24 |
REPO_ID = "Kgshop/nizhbel"
|
| 25 |
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
|
| 26 |
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 27 |
+
|
| 28 |
STORE_ADDRESS = "Рынок Кербент, 6 ряд , 3 контейнер / 5 ряд 25 контейнер "
|
| 29 |
+
|
| 30 |
CURRENCY_CODE = 'KGS'
|
| 31 |
CURRENCY_NAME = 'Кыргызский сом'
|
| 32 |
+
|
| 33 |
DOWNLOAD_RETRIES = 3
|
| 34 |
DOWNLOAD_DELAY = 5
|
| 35 |
+
|
| 36 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 37 |
+
|
| 38 |
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
|
| 39 |
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
|
| 40 |
logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
|
| 41 |
+
|
| 42 |
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
|
| 43 |
+
|
| 44 |
files_to_download = [specific_file] if specific_file else SYNC_FILES
|
| 45 |
logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
|
| 46 |
all_successful = True
|
| 47 |
+
|
| 48 |
for file_name in files_to_download:
|
| 49 |
success = False
|
| 50 |
for attempt in range(retries + 1):
|
|
|
|
| 85 |
logging.error(f"Network error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
|
| 86 |
except Exception as e:
|
| 87 |
logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
|
| 88 |
+
|
| 89 |
if attempt < retries:
|
| 90 |
time.sleep(delay)
|
| 91 |
+
|
| 92 |
if not success:
|
| 93 |
logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
|
| 94 |
all_successful = False
|
| 95 |
+
|
| 96 |
logging.info(f"Download process finished. Overall success: {all_successful}")
|
| 97 |
return all_successful
|
| 98 |
+
|
| 99 |
def upload_db_to_hf(specific_file=None):
|
| 100 |
if not HF_TOKEN_WRITE:
|
| 101 |
logging.warning("HF_TOKEN (for writing) not set. Skipping upload to Hugging Face.")
|
|
|
|
| 104 |
api = HfApi()
|
| 105 |
files_to_upload = [specific_file] if specific_file else SYNC_FILES
|
| 106 |
logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
|
| 107 |
+
|
| 108 |
for file_name in files_to_upload:
|
| 109 |
if os.path.exists(file_name):
|
| 110 |
try:
|
|
|
|
| 124 |
logging.info("Finished uploading files to HF.")
|
| 125 |
except Exception as e:
|
| 126 |
logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
|
| 127 |
+
|
| 128 |
def periodic_backup():
|
| 129 |
backup_interval = 1800
|
| 130 |
logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
|
|
|
|
| 133 |
logging.info("Starting periodic backup...")
|
| 134 |
upload_db_to_hf()
|
| 135 |
logging.info("Periodic backup finished.")
|
| 136 |
+
|
| 137 |
def load_data():
|
| 138 |
default_data = {'products': [], 'categories': [], 'orders': {}}
|
| 139 |
try:
|
|
|
|
| 151 |
logging.warning(f"Local file {DATA_FILE} not found. Attempting download from HF.")
|
| 152 |
except json.JSONDecodeError:
|
| 153 |
logging.error(f"Error decoding JSON in local {DATA_FILE}. File might be corrupt. Attempting download.")
|
| 154 |
+
|
| 155 |
if download_db_from_hf(specific_file=DATA_FILE):
|
| 156 |
try:
|
| 157 |
with open(DATA_FILE, 'r', encoding='utf-8') as file:
|
|
|
|
| 183 |
except Exception as create_e:
|
| 184 |
logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}")
|
| 185 |
return default_data
|
| 186 |
+
|
| 187 |
+
|
| 188 |
def save_data(data):
|
| 189 |
try:
|
| 190 |
if not isinstance(data, dict):
|
|
|
|
| 193 |
if 'products' not in data: data['products'] = []
|
| 194 |
if 'categories' not in data: data['categories'] = []
|
| 195 |
if 'orders' not in data: data['orders'] = {}
|
| 196 |
+
|
| 197 |
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 198 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 199 |
logging.info(f"Data successfully saved to {DATA_FILE}")
|
| 200 |
upload_db_to_hf(specific_file=DATA_FILE)
|
| 201 |
except Exception as e:
|
| 202 |
logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
CATALOG_TEMPLATE = '''
|
| 206 |
<!DOCTYPE html>
|
| 207 |
<html lang="ru">
|
|
|
|
| 214 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
|
| 215 |
<style>
|
| 216 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 217 |
+
body { font-family: 'Poppins', sans-serif; background: #ffffff; color: #333333; line-height: 1.6; }
|
|
|
|
| 218 |
.container { max-width: 1300px; margin: 0 auto; padding: 20px; }
|
| 219 |
+
.header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #e0e0e0; }
|
| 220 |
+
.header h1 { font-size: 1.8rem; font-weight: 600; color: #E91E63; }
|
| 221 |
+
.store-address { padding: 15px; text-align: center; background-color: #f9f9f9; margin: 20px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.03); font-size: 1rem; color: #666; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
.filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
| 223 |
.search-container { margin: 20px 0; text-align: center; }
|
| 224 |
+
#search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid #e0e0e0; border-radius: 25px; outline: none; box-shadow: 0 2px 5px rgba(0,0,0,0.03); transition: all 0.3s ease; }
|
| 225 |
+
#search-input:focus { border-color: #E91E63; box-shadow: 0 0 0 3px rgba(233, 30, 99, 0.15); }
|
| 226 |
+
.category-filter { padding: 8px 16px; border: 1px solid #e0e0e0; 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: #C2185B; }
|
| 227 |
+
.category-filter.active, .category-filter:hover { background-color: #E91E63; color: white; border-color: #E91E63; box-shadow: 0 2px 10px rgba(233, 30, 99, 0.2); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
.products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 20px; padding: 10px; }
|
| 229 |
@media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } }
|
| 230 |
@media (min-width: 900px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
|
| 231 |
+
|
| 232 |
+
.product { background: #fff; border-radius: 15px; padding: 0; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05); 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 #f0f0f0;}
|
| 233 |
+
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1); }
|
|
|
|
| 234 |
.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; }
|
| 235 |
.product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; }
|
| 236 |
.product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
|
| 237 |
+
.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: #333; }
|
| 238 |
+
.product-price { font-size: 1.2rem; color: #E91E63; font-weight: 700; text-align: center; margin: 5px 0; }
|
| 239 |
+
.product-description { font-size: 0.85rem; color: #666; text-align: center; margin-bottom: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
|
|
|
|
|
| 240 |
.product-actions { padding: 0 15px 15px 15px; display: flex; flex-direction: column; gap: 8px; }
|
| 241 |
+
.product-button { display: block; width: 100%; padding: 10px; border: none; border-radius: 8px; background-color: #F06292; 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; }
|
| 242 |
+
.product-button:hover { background-color: #E91E63; box-shadow: 0 4px 15px rgba(233, 30, 99, 0.3); transform: translateY(-2px); }
|
| 243 |
.product-button i { margin-right: 5px; }
|
| 244 |
+
.add-to-cart { background-color: #E91E63; }
|
| 245 |
+
.add-to-cart:hover { background-color: #C2185B; box-shadow: 0 4px 15px rgba(194, 24, 91, 0.4); }
|
| 246 |
+
#cart-button { position: fixed; bottom: 25px; right: 25px; background-color: #E91E63; 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(233, 30, 99, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; }
|
| 247 |
+
#cart-button:hover { background-color: #C2185B; box-shadow: 0 6px 20px rgba(194, 24, 91, 0.5); }
|
| 248 |
#cart-button .fa-shopping-cart { margin-right: 0; }
|
| 249 |
+
#cart-button span { position: absolute; top: -5px; right: -5px; background-color: #C2185B; color: white; border-radius: 50%; padding: 2px 6px; font-size: 0.7rem; font-weight: bold; }
|
| 250 |
+
.modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); backdrop-filter: blur(3px); overflow-y: auto; }
|
| 251 |
+
.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.1); animation: slideIn 0.3s ease-out; position: relative; }
|
|
|
|
| 252 |
@keyframes slideIn { from { transform: translateY(-30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
| 253 |
.close { position: absolute; top: 15px; right: 15px; font-size: 1.8rem; color: #aaa; cursor: pointer; transition: color 0.3s; line-height: 1; }
|
| 254 |
+
.close:hover { color: #666; }
|
| 255 |
+
.modal-content h2 { margin-top: 0; margin-bottom: 20px; color: #E91E63; display: flex; align-items: center; gap: 10px;}
|
| 256 |
+
.cart-item { display: grid; grid-template-columns: auto 1fr auto auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #e0e0e0; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
.cart-item:last-child { border-bottom: none; }
|
| 258 |
+
.cart-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; grid-column: 1; border: 1px solid #e0e0e0;}
|
| 259 |
.cart-item-details { grid-column: 2; }
|
| 260 |
+
.cart-item-details strong { display: block; margin-bottom: 5px; font-size: 1rem; color: #333;}
|
| 261 |
+
.cart-item-price { font-size: 0.9rem; color: #666; }
|
| 262 |
+
.cart-item-total { font-weight: bold; text-align: right; grid-column: 3; font-size: 1rem; color: #C2185B;}
|
| 263 |
+
.cart-item-remove { grid-column: 4; background:none; border:none; color:#dc3545; cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; }
|
| 264 |
+
.cart-item-remove:hover { color: #c82333; }
|
| 265 |
+
.quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #e0e0e0; border-radius: 8px; font-size: 1rem; margin: 10px 0; box-sizing: border-box; }
|
| 266 |
+
.quantity-input:focus, .color-select:focus { border-color: #F06292; outline: none; box-shadow: 0 0 0 2px rgba(240, 98, 146, 0.1); }
|
| 267 |
+
.cart-summary { margin-top: 20px; text-align: right; border-top: 1px solid #e0e0e0; padding-top: 15px; }
|
| 268 |
+
.cart-summary strong { font-size: 1.2rem; color: #E91E63;}
|
|
|
|
|
|
|
| 269 |
.cart-actions { margin-top: 25px; display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
| 270 |
.cart-actions .product-button { width: auto; flex-grow: 1; }
|
| 271 |
+
.clear-cart { background-color: #6c757d; }
|
| 272 |
+
.clear-cart:hover { background-color: #5a6268; box-shadow: 0 4px 15px rgba(90, 98, 104, 0.4); }
|
| 273 |
+
.formulate-order-button { background-color: #E91E63; }
|
| 274 |
+
.formulate-order-button:hover { background-color: #C2185B; box-shadow: 0 4px 15px rgba(194, 24, 91, 0.4); }
|
| 275 |
+
.notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: #E91E63; 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;}
|
| 276 |
.notification.show { opacity: 1;}
|
| 277 |
+
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #999; }
|
|
|
|
| 278 |
.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); }
|
| 279 |
.product { position: relative; }
|
| 280 |
</style>
|
|
|
|
| 283 |
<div class="container">
|
| 284 |
<div class="header">
|
| 285 |
<div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;">
|
|
|
|
| 286 |
<h1>Meka Shop</h1>
|
| 287 |
</div>
|
|
|
|
|
|
|
|
|
|
| 288 |
</div>
|
| 289 |
+
|
| 290 |
<div class="store-address">Наш адрес: {{ store_address }}</div>
|
| 291 |
+
|
| 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 |
+
|
| 299 |
<div class="search-container">
|
| 300 |
<input type="text" id="search-input" placeholder="Поиск по названию или описанию...">
|
| 301 |
</div>
|
| 302 |
+
|
| 303 |
<div class="products-grid" id="products-grid">
|
| 304 |
{% for product in products %}
|
| 305 |
<div class="product"
|
|
|
|
| 336 |
{% endif %}
|
| 337 |
</div>
|
| 338 |
</div>
|
| 339 |
+
|
| 340 |
<div id="productModal" class="modal">
|
| 341 |
<div class="modal-content">
|
| 342 |
<span class="close" onclick="closeModal('productModal')" aria-label="Закрыть">×</span>
|
| 343 |
<div id="modalContent">Загрузка...</div>
|
| 344 |
</div>
|
| 345 |
</div>
|
| 346 |
+
|
| 347 |
<div id="quantityModal" class="modal">
|
| 348 |
<div class="modal-content">
|
| 349 |
<span class="close" onclick="closeModal('quantityModal')" aria-label="Закрыть">×</span>
|
|
|
|
| 355 |
<button class="product-button add-to-cart" onclick="confirmAddToCart()"><i class="fas fa-check"></i> Добавить в корзину</button>
|
| 356 |
</div>
|
| 357 |
</div>
|
| 358 |
+
|
| 359 |
<div id="cartModal" class="modal">
|
| 360 |
<div class="modal-content">
|
| 361 |
<span class="close" onclick="closeModal('cartModal')" aria-label="Закрыть">×</span>
|
|
|
|
| 374 |
</div>
|
| 375 |
</div>
|
| 376 |
</div>
|
| 377 |
+
|
| 378 |
<button id="cart-button" onclick="openCartModal()" aria-label="Открыть корзину">
|
| 379 |
<i class="fas fa-shopping-cart"></i>
|
| 380 |
<span id="cart-count">0</span>
|
| 381 |
</button>
|
| 382 |
+
|
| 383 |
<div id="notification-placeholder"></div>
|
| 384 |
+
|
| 385 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
|
| 386 |
<script>
|
| 387 |
const products = {{ products|tojson }};
|
| 388 |
const repoId = '{{ repo_id }}';
|
| 389 |
const currencyCode = '{{ currency_code }}';
|
| 390 |
let selectedProductIndex = null;
|
| 391 |
+
let cart = JSON.parse(localStorage.getItem('mekaCart') || '[]');
|
| 392 |
+
|
| 393 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
function openModal(index) {
|
| 395 |
loadProductDetails(index);
|
| 396 |
const modal = document.getElementById('productModal');
|
|
|
|
| 399 |
document.body.style.overflow = 'hidden';
|
| 400 |
}
|
| 401 |
}
|
| 402 |
+
|
| 403 |
function closeModal(modalId) {
|
| 404 |
const modal = document.getElementById(modalId);
|
| 405 |
if (modal) {
|
|
|
|
| 410 |
document.body.style.overflow = 'auto';
|
| 411 |
}
|
| 412 |
}
|
| 413 |
+
|
| 414 |
function loadProductDetails(index) {
|
| 415 |
const modalContent = document.getElementById('modalContent');
|
| 416 |
if (!modalContent) return;
|
|
|
|
| 426 |
})
|
| 427 |
.catch(error => {
|
| 428 |
console.error('Ошибка загрузки деталей продукта:', error);
|
| 429 |
+
modalContent.innerHTML = `<p style="color: #dc3545; text-align:center; padding: 40px;">Не удалось загрузить информацию о товаре. ${error.message}</p>`;
|
| 430 |
});
|
| 431 |
}
|
| 432 |
+
|
| 433 |
function initializeSwiper() {
|
| 434 |
const swiperContainer = document.querySelector('#productModal .swiper-container');
|
| 435 |
if (swiperContainer) {
|
|
|
|
| 445 |
});
|
| 446 |
}
|
| 447 |
}
|
| 448 |
+
|
| 449 |
function openQuantityModal(index) {
|
| 450 |
selectedProductIndex = index;
|
| 451 |
const product = products[index];
|
|
|
|
| 454 |
alert("Ошибка: товар не найден.");
|
| 455 |
return;
|
| 456 |
}
|
| 457 |
+
|
| 458 |
const colorSelect = document.getElementById('colorSelect');
|
| 459 |
const colorLabel = document.querySelector('label[for="colorSelect"]');
|
| 460 |
colorSelect.innerHTML = '';
|
| 461 |
+
|
| 462 |
const validColors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : [];
|
| 463 |
+
|
| 464 |
if (validColors.length > 0) {
|
| 465 |
validColors.forEach(color => {
|
| 466 |
const option = document.createElement('option');
|
|
|
|
| 474 |
colorSelect.style.display = 'none';
|
| 475 |
if(colorLabel) colorLabel.style.display = 'none';
|
| 476 |
}
|
| 477 |
+
|
| 478 |
document.getElementById('quantityInput').value = 1;
|
| 479 |
const modal = document.getElementById('quantityModal');
|
| 480 |
if(modal) {
|
|
|
|
| 482 |
document.body.style.overflow = 'hidden';
|
| 483 |
}
|
| 484 |
}
|
| 485 |
+
|
| 486 |
function confirmAddToCart() {
|
| 487 |
if (selectedProductIndex === null) return;
|
| 488 |
+
|
| 489 |
const quantityInput = document.getElementById('quantityInput');
|
| 490 |
const quantity = parseInt(quantityInput.value);
|
| 491 |
const colorSelect = document.getElementById('colorSelect');
|
| 492 |
const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A';
|
| 493 |
+
|
| 494 |
if (isNaN(quantity) || quantity <= 0) {
|
| 495 |
alert("Пожалуйста, укажите корректное количество (больше 0).");
|
| 496 |
quantityInput.focus();
|
| 497 |
return;
|
| 498 |
}
|
| 499 |
+
|
| 500 |
const product = products[selectedProductIndex];
|
| 501 |
if (!product) {
|
| 502 |
alert("Ошибка добавления: товар не найден.");
|
| 503 |
return;
|
| 504 |
}
|
| 505 |
+
|
| 506 |
const cartItemId = `${product.name}-${color}`;
|
| 507 |
const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
|
| 508 |
+
|
| 509 |
if (existingItemIndex > -1) {
|
| 510 |
cart[existingItemIndex].quantity += quantity;
|
| 511 |
} else {
|
|
|
|
| 518 |
color: color
|
| 519 |
});
|
| 520 |
}
|
| 521 |
+
|
| 522 |
+
localStorage.setItem('mekaCart', JSON.stringify(cart));
|
| 523 |
closeModal('quantityModal');
|
| 524 |
updateCartButton();
|
| 525 |
showNotification(`${product.name} добавлен в корзину!`);
|
| 526 |
}
|
| 527 |
+
|
| 528 |
function updateCartButton() {
|
| 529 |
const cartCountElement = document.getElementById('cart-count');
|
| 530 |
const cartButton = document.getElementById('cart-button');
|
| 531 |
if (!cartCountElement || !cartButton) return;
|
| 532 |
+
|
| 533 |
let totalItems = 0;
|
| 534 |
cart.forEach(item => { totalItems += item.quantity; });
|
| 535 |
+
|
| 536 |
if (totalItems > 0) {
|
| 537 |
cartCountElement.textContent = totalItems;
|
| 538 |
cartButton.style.display = 'flex';
|
|
|
|
| 541 |
cartButton.style.display = 'none';
|
| 542 |
}
|
| 543 |
}
|
| 544 |
+
|
| 545 |
function openCartModal() {
|
| 546 |
const cartContent = document.getElementById('cartContent');
|
| 547 |
const cartTotalElement = document.getElementById('cartTotal');
|
| 548 |
if (!cartContent || !cartTotalElement) return;
|
| 549 |
+
|
| 550 |
let total = 0;
|
| 551 |
+
|
| 552 |
if (cart.length === 0) {
|
| 553 |
cartContent.innerHTML = '<p style="text-align: center; padding: 20px;">Ваша корзина пуста.</p>';
|
| 554 |
cartTotalElement.textContent = '0.00';
|
|
|
|
| 560 |
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}`
|
| 561 |
: 'https://via.placeholder.com/60x60.png?text=N/A';
|
| 562 |
const colorText = item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
|
| 563 |
+
|
| 564 |
return `
|
| 565 |
<div class="cart-item">
|
| 566 |
<img src="${photoUrl}" alt="${item.name}">
|
|
|
|
| 581 |
document.body.style.overflow = 'hidden';
|
| 582 |
}
|
| 583 |
}
|
| 584 |
+
|
| 585 |
function removeFromCart(itemId) {
|
| 586 |
cart = cart.filter(item => item.id !== itemId);
|
| 587 |
+
localStorage.setItem('mekaCart', JSON.stringify(cart));
|
| 588 |
openCartModal();
|
| 589 |
updateCartButton();
|
| 590 |
}
|
| 591 |
+
|
| 592 |
function clearCart() {
|
| 593 |
if (confirm("Вы уверены, что хотите очистить корзину?")) {
|
| 594 |
cart = [];
|
| 595 |
+
localStorage.removeItem('mekaCart');
|
| 596 |
openCartModal();
|
| 597 |
updateCartButton();
|
| 598 |
}
|
| 599 |
}
|
| 600 |
+
|
| 601 |
function formulateOrder() {
|
| 602 |
if (cart.length === 0) {
|
| 603 |
alert("Корзина пуста! Добавьте товары перед формированием заказа.");
|
| 604 |
return;
|
| 605 |
}
|
| 606 |
+
|
| 607 |
const orderData = {
|
| 608 |
cart: cart
|
| 609 |
};
|
| 610 |
+
|
| 611 |
const formulateButton = document.querySelector('.formulate-order-button');
|
| 612 |
if (formulateButton) formulateButton.disabled = true;
|
| 613 |
+
|
| 614 |
showNotification("Формируем заказ...", 5000);
|
| 615 |
+
|
| 616 |
fetch('/create_order', {
|
| 617 |
method: 'POST',
|
| 618 |
headers: { 'Content-Type': 'application/json' },
|
|
|
|
| 626 |
})
|
| 627 |
.then(data => {
|
| 628 |
if (data.order_id) {
|
| 629 |
+
localStorage.removeItem('mekaCart');
|
| 630 |
cart = [];
|
| 631 |
updateCartButton();
|
| 632 |
closeModal('cartModal');
|
|
|
|
| 641 |
if (formulateButton) formulateButton.disabled = false;
|
| 642 |
});
|
| 643 |
}
|
| 644 |
+
|
| 645 |
+
|
| 646 |
function filterProducts() {
|
| 647 |
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
|
| 648 |
const activeCategoryButton = document.querySelector('.category-filter.active');
|
| 649 |
const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all';
|
| 650 |
const grid = document.getElementById('products-grid');
|
| 651 |
let visibleProducts = 0;
|
| 652 |
+
|
| 653 |
const existingNoResults = grid.querySelector('.no-results-message');
|
| 654 |
if (existingNoResults) existingNoResults.remove();
|
| 655 |
+
|
| 656 |
document.querySelectorAll('.products-grid .product').forEach(productElement => {
|
| 657 |
const name = productElement.getAttribute('data-name');
|
| 658 |
const description = productElement.getAttribute('data-description');
|
| 659 |
const category = productElement.getAttribute('data-category');
|
| 660 |
+
|
| 661 |
const matchesSearch = !searchTerm || name.includes(searchTerm) || description.includes(searchTerm);
|
| 662 |
const matchesCategory = activeCategory === 'all' || category === activeCategory;
|
| 663 |
+
|
| 664 |
if (matchesSearch && matchesCategory) {
|
| 665 |
productElement.style.display = 'flex';
|
| 666 |
visibleProducts++;
|
|
|
|
| 668 |
productElement.style.display = 'none';
|
| 669 |
}
|
| 670 |
});
|
| 671 |
+
|
| 672 |
if (visibleProducts === 0 && products.length > 0) {
|
| 673 |
const p = document.createElement('p');
|
| 674 |
p.className = 'no-results-message';
|
|
|
|
| 681 |
grid.appendChild(p);
|
| 682 |
}
|
| 683 |
}
|
| 684 |
+
|
| 685 |
function setupFilters() {
|
| 686 |
const searchInput = document.getElementById('search-input');
|
| 687 |
const categoryFilters = document.querySelectorAll('.category-filter');
|
| 688 |
+
|
| 689 |
if(searchInput) searchInput.addEventListener('input', filterProducts);
|
| 690 |
+
|
| 691 |
categoryFilters.forEach(filter => {
|
| 692 |
filter.addEventListener('click', function() {
|
| 693 |
categoryFilters.forEach(f => f.classList.remove('active'));
|
|
|
|
| 697 |
});
|
| 698 |
filterProducts();
|
| 699 |
}
|
| 700 |
+
|
| 701 |
function showNotification(message, duration = 3000) {
|
| 702 |
const placeholder = document.getElementById('notification-placeholder');
|
| 703 |
if (!placeholder) {
|
|
|
|
| 711 |
document.body.appendChild(newPlaceholder);
|
| 712 |
placeholder = newPlaceholder;
|
| 713 |
}
|
| 714 |
+
|
| 715 |
+
|
| 716 |
const notification = document.createElement('div');
|
| 717 |
notification.className = 'notification';
|
| 718 |
notification.textContent = message;
|
| 719 |
placeholder.appendChild(notification);
|
| 720 |
+
|
| 721 |
void notification.offsetWidth;
|
| 722 |
+
|
| 723 |
notification.classList.add('show');
|
| 724 |
+
|
| 725 |
setTimeout(() => {
|
| 726 |
notification.classList.remove('show');
|
| 727 |
notification.addEventListener('transitionend', () => notification.remove());
|
| 728 |
}, duration);
|
| 729 |
}
|
| 730 |
+
|
| 731 |
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
| 732 |
updateCartButton();
|
| 733 |
setupFilters();
|
| 734 |
+
|
| 735 |
window.addEventListener('click', function(event) {
|
| 736 |
if (event.target.classList.contains('modal')) {
|
| 737 |
closeModal(event.target.id);
|
| 738 |
}
|
| 739 |
});
|
| 740 |
+
|
| 741 |
window.addEventListener('keydown', function(event) {
|
| 742 |
if (event.key === 'Escape') {
|
| 743 |
document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
|
|
|
|
| 746 |
}
|
| 747 |
});
|
| 748 |
});
|
| 749 |
+
|
| 750 |
</script>
|
| 751 |
</body>
|
| 752 |
</html>
|
| 753 |
'''
|
| 754 |
+
|
| 755 |
PRODUCT_DETAIL_TEMPLATE = '''
|
| 756 |
<div style="padding: 10px;">
|
| 757 |
+
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #E91E63;">{{ product['name'] }}</h2>
|
| 758 |
+
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff; border: 1px solid #e0e0e0;">
|
| 759 |
<div class="swiper-wrapper">
|
| 760 |
{% if product.get('photos') and product['photos']|length > 0 %}
|
| 761 |
{% for photo in product['photos'] %}
|
|
|
|
| 775 |
</div>
|
| 776 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 777 |
<div class="swiper-pagination" style="position: relative; bottom: 5px;"></div>
|
| 778 |
+
<div class="swiper-button-next" style="color: #E91E63;"></div>
|
| 779 |
+
<div class="swiper-button-prev" style="color: #E91E63;"></div>
|
| 780 |
{% endif %}
|
| 781 |
</div>
|
| 782 |
+
|
| 783 |
+
<div style="margin-top: 20px; font-size: 1rem; line-height: 1.7; color: #333;">
|
| 784 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 785 |
+
<p style="font-size: 1.2rem; font-weight: bold; color: #C2185B;"><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 786 |
<p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\\n', '<br>')|safe }}</p>
|
| 787 |
{% set colors = product.get('colors', []) %}
|
| 788 |
{% if colors and colors|select('ne', '')|list|length > 0 %}
|
|
|
|
| 791 |
</div>
|
| 792 |
</div>
|
| 793 |
'''
|
| 794 |
+
|
| 795 |
ORDER_TEMPLATE = '''
|
| 796 |
<!DOCTYPE html>
|
| 797 |
<html lang="ru">
|
|
|
|
| 802 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 803 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 804 |
<style>
|
| 805 |
+
body { font-family: 'Poppins', sans-serif; background: #ffffff; color: #333; line-height: 1.6; padding: 20px; }
|
| 806 |
+
.container { max-width: 800px; margin: 20px auto; padding: 30px; background: #fff; border-radius: 15px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); border: 1px solid #e0e0e0; }
|
| 807 |
+
h1 { text-align: center; color: #E91E63; margin-bottom: 25px; font-size: 1.8rem; font-weight: 600; }
|
| 808 |
+
h2 { color: #C2185B; margin-top: 30px; margin-bottom: 15px; font-size: 1.4rem; border-bottom: 1px solid #e0e0e0; padding-bottom: 8px;}
|
| 809 |
+
.order-meta { font-size: 0.9rem; color: #999; margin-bottom: 20px; text-align: center; }
|
| 810 |
+
.order-item { display: grid; grid-template-columns: 60px 1fr auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #f0f0f0; }
|
| 811 |
.order-item:last-child { border-bottom: none; }
|
| 812 |
+
.order-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; border: 1px solid #e0e0e0;}
|
| 813 |
+
.item-details strong { display: block; margin-bottom: 5px; font-size: 1.05rem; color: #333;}
|
| 814 |
+
.item-details span { font-size: 0.9rem; color: #666; display: block;}
|
| 815 |
+
.item-total { font-weight: bold; text-align: right; font-size: 1rem; color: #C2185B;}
|
| 816 |
+
.order-summary { margin-top: 30px; padding-top: 20px; border-top: 2px solid #E91E63; text-align: right; }
|
| 817 |
.order-summary p { margin-bottom: 10px; font-size: 1.1rem; }
|
| 818 |
+
.order-summary strong { font-size: 1.3rem; color: #E91E63; }
|
| 819 |
+
.customer-info { margin-top: 30px; background-color: #f9f9f9; padding: 20px; border-radius: 8px; border: 1px solid #e0e0e0;}
|
| 820 |
.customer-info p { margin-bottom: 8px; font-size: 0.95rem; }
|
| 821 |
+
.customer-info strong { color: #C2185B; }
|
| 822 |
.actions { margin-top: 30px; text-align: center; }
|
| 823 |
+
.button { padding: 12px 25px; border: none; border-radius: 8px; background-color: #E91E63; 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; }
|
| 824 |
+
.button:hover { background-color: #C2185B; }
|
| 825 |
.button:active { transform: scale(0.98); }
|
| 826 |
.button i { font-size: 1.2rem; }
|
| 827 |
+
.catalog-link { display: block; text-align: center; margin-top: 25px; color: #E91E63; text-decoration: none; font-size: 0.9rem; }
|
| 828 |
.catalog-link:hover { text-decoration: underline; }
|
| 829 |
+
.not-found { text-align: center; color: #dc3545; font-size: 1.2rem; padding: 40px 0;}
|
| 830 |
</style>
|
| 831 |
</head>
|
| 832 |
<body>
|
|
|
|
| 834 |
{% if order %}
|
| 835 |
<h1><i class="fas fa-receipt"></i> Ваш Заказ №{{ order.id }}</h1>
|
| 836 |
<p class="order-meta">Дата создания: {{ order.created_at }}</p>
|
| 837 |
+
|
| 838 |
<h2><i class="fas fa-shopping-bag"></i> Товары в заказе</h2>
|
| 839 |
<div id="orderItems">
|
| 840 |
{% for item in order.cart %}
|
|
|
|
| 850 |
</div>
|
| 851 |
{% endfor %}
|
| 852 |
</div>
|
| 853 |
+
|
| 854 |
<div class="order-summary">
|
| 855 |
<p>Общая сумма товаров: <strong>{{ "%.2f"|format(order.total_price) }} {{ currency_code }}</strong></p>
|
| 856 |
<p><strong>ИТОГО К ОПЛАТЕ: {{ "%.2f"|format(order.total_price) }} {{ currency_code }}</strong></p>
|
| 857 |
</div>
|
| 858 |
+
|
| 859 |
<div class="customer-info">
|
| 860 |
<h2><i class="fas fa-info-circle"></i> Статус заказа</h2>
|
| 861 |
<p>Этот заказ был оформлен без входа в систему.</p>
|
| 862 |
<p>Пожалуйста, свяжитесь с нами по WhatsApp для подтверждения и уточнения деталей.</p>
|
| 863 |
</div>
|
| 864 |
+
|
| 865 |
<div class="actions">
|
| 866 |
<button class="button" onclick="sendOrderViaWhatsApp()"><i class="fab fa-whatsapp"></i> Отправить заказ</button>
|
| 867 |
</div>
|
| 868 |
+
|
| 869 |
<a href="{{ url_for('catalog') }}" class="catalog-link">← Вернуться в каталог</a>
|
| 870 |
+
|
| 871 |
<script>
|
| 872 |
function sendOrderViaWhatsApp() {
|
| 873 |
const orderId = '{{ order.id }}';
|
| 874 |
const orderUrl = `{{ request.url }}`;
|
| 875 |
+
const whatsappNumber = "996509455959";
|
| 876 |
+
|
| 877 |
let message = `Здравствуйте! Хочу подтвердить свой заказ на Meka Shop:%0A%0A`;
|
| 878 |
message += `*Номер заказа:* ${orderId}%0A`;
|
| 879 |
message += `*Ссылка на заказ:* ${encodeURIComponent(orderUrl)}%0A%0A`;
|
| 880 |
message += `Пожалуйста, свяжитесь со мной для уточнения деталей оплаты и доставки.`;
|
| 881 |
+
|
| 882 |
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${message}`;
|
| 883 |
window.open(whatsappUrl, '_blank');
|
| 884 |
}
|
| 885 |
</script>
|
| 886 |
+
|
| 887 |
{% else %}
|
| 888 |
+
<h1 style="color: #dc3545;"><i class="fas fa-exclamation-triangle"></i> Ошибка</h1>
|
| 889 |
<p class="not-found">Заказ с таким ID не найден.</p>
|
| 890 |
<a href="{{ url_for('catalog') }}" class="catalog-link">← Вернуться в каталог</a>
|
| 891 |
{% endif %}
|
|
|
|
| 893 |
</body>
|
| 894 |
</html>
|
| 895 |
'''
|
| 896 |
+
|
| 897 |
ADMIN_TEMPLATE = '''
|
| 898 |
<!DOCTYPE html>
|
| 899 |
<html lang="ru">
|
|
|
|
| 904 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 905 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 906 |
<style>
|
| 907 |
+
body { font-family: 'Poppins', sans-serif; background-color: #ffffff; color: #333; padding: 20px; line-height: 1.6; }
|
| 908 |
.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); }
|
| 909 |
+
.header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
|
| 910 |
+
h1, h2, h3 { font-weight: 600; color: #E91E63; margin-bottom: 15px; }
|
| 911 |
h1 { font-size: 1.8rem; }
|
| 912 |
h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
|
| 913 |
+
h3 { font-size: 1.2rem; color: #C2185B; margin-top: 20px; }
|
| 914 |
+
.section { margin-bottom: 30px; padding: 20px; background-color: #f9f9f9; border: 1px solid #e0e0e0; border-radius: 8px; }
|
| 915 |
form { margin-bottom: 20px; }
|
| 916 |
+
label { font-weight: 500; margin-top: 10px; display: block; color: #666; font-size: 0.9rem;}
|
| 917 |
+
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 #e0e0e0; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; background-color: #fff; }
|
| 918 |
+
input:focus, textarea:focus, select:focus { border-color: #E91E63; outline: none; box-shadow: 0 0 0 2px rgba(233, 30, 99, 0.1); }
|
| 919 |
textarea { min-height: 80px; resize: vertical; }
|
| 920 |
+
input[type="file"] { padding: 8px; background-color: #ffffff; cursor: pointer; border: 1px solid #e0e0e0;}
|
| 921 |
+
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #f0f0f0; border: 1px solid #e0e0e0; cursor: pointer; margin-right: 10px;}
|
| 922 |
input[type="checkbox"] { margin-right: 5px; vertical-align: middle; }
|
| 923 |
label.inline-label { display: inline-block; margin-top: 10px; font-weight: normal; }
|
| 924 |
+
button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #F06292; 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;}
|
| 925 |
+
button:hover, .button:hover { background-color: #E91E63; }
|
| 926 |
button:active, .button:active { transform: scale(0.98); }
|
| 927 |
button[type="submit"] { min-width: 120px; justify-content: center; }
|
| 928 |
+
.delete-button { background-color: #dc3545; }
|
| 929 |
+
.delete-button:hover { background-color: #c82333; }
|
| 930 |
+
.add-button { background-color: #E91E63; }
|
| 931 |
+
.add-button:hover { background-color: #C2185B; }
|
| 932 |
.item-list { display: grid; gap: 20px; }
|
| 933 |
+
.item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.03); border: 1px solid #f0f0f0; }
|
| 934 |
+
.item p { margin: 5px 0; font-size: 0.9rem; color: #666; }
|
| 935 |
+
.item strong { color: #333; }
|
| 936 |
+
.item .description { font-size: 0.85rem; color: #999; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
|
| 937 |
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
| 938 |
+
.item-actions button:not(.delete-button) { background-color: #F06292; }
|
| 939 |
+
.item-actions button:not(.delete-button):hover { background-color: #E91E63; }
|
| 940 |
+
.edit-form-container { margin-top: 15px; padding: 20px; background: #fff7fa; border: 1px dashed #e0e0e0; border-radius: 6px; display: none; }
|
| 941 |
+
details { background-color: #f9f9f9; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 20px; }
|
| 942 |
+
details > summary { cursor: pointer; font-weight: 600; color: #C2185B; display: block; padding: 15px; border-bottom: 1px solid #e0e0e0; list-style: none; position: relative; }
|
| 943 |
+
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: #E91E63; }
|
| 944 |
details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
|
| 945 |
+
details[open] > summary { border-bottom: 1px solid #e0e0e0; }
|
| 946 |
details .form-content { padding: 20px; }
|
| 947 |
.color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
| 948 |
.color-input-group input { flex-grow: 1; margin: 0; }
|
| 949 |
+
.remove-color-btn { background-color: #dc3545; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; }
|
| 950 |
+
.remove-color-btn:hover { background-color: #c82333; }
|
| 951 |
+
.add-color-btn { background-color: #F8BBD0; color: #C2185B; border: 1px solid #e0e0e0; }
|
| 952 |
+
.add-color-btn:hover { background-color: #E91E63; color: white; border-color: #E91E63; }
|
| 953 |
+
.photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #e0e0e0; object-fit: cover;}
|
| 954 |
.sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
|
| 955 |
+
.download-hf-button { background-color: #6c757d; }
|
| 956 |
+
.download-hf-button:hover { background-color: #5a6268; }
|
| 957 |
.flex-container { display: flex; flex-wrap: wrap; gap: 20px; }
|
| 958 |
.flex-item { flex: 1; min-width: 350px; }
|
| 959 |
.message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;}
|
| 960 |
+
.message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
|
| 961 |
+
.message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
|
| 962 |
+
.message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
|
| 963 |
.status-indicator { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 500; margin-left: 10px; vertical-align: middle; }
|
| 964 |
+
.status-indicator.in-stock { background-color: #d4edda; color: #155724; }
|
| 965 |
+
.status-indicator.out-of-stock { background-color: #f8d7da; color: #721c24; }
|
| 966 |
+
.status-indicator.top-product { background-color: #fff3cd; color: #856404; margin-left: 5px;}
|
| 967 |
</style>
|
| 968 |
</head>
|
| 969 |
<body>
|
| 970 |
<div class="container">
|
| 971 |
<div class="header">
|
| 972 |
<div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;">
|
|
|
|
| 973 |
<h1><i class="fas fa-tools"></i> Админ-панель Meka Shop</h1>
|
| 974 |
</div>
|
| 975 |
+
<a href="{{ url_for('catalog') }}" class="button" style="background-color: #E91E63;"><i class="fas fa-store"></i> Перейти в каталог</a>
|
| 976 |
</div>
|
| 977 |
+
|
| 978 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 979 |
{% if messages %}
|
| 980 |
{% for category, message in messages %}
|
|
|
|
| 982 |
{% endfor %}
|
| 983 |
{% endif %}
|
| 984 |
{% endwith %}
|
| 985 |
+
|
| 986 |
<div class="section">
|
| 987 |
<h2><i class="fas fa-sync-alt"></i> Синхронизация с Датацентром</h2>
|
| 988 |
<div class="sync-buttons">
|
|
|
|
| 993 |
<button type="submit" class="button download-hf-button" title="Скачать файлы (перезапишет локальные)"><i class="fas fa-download"></i> Скачать БД</button>
|
| 994 |
</form>
|
| 995 |
</div>
|
| 996 |
+
<p style="font-size: 0.85rem; color: #999;">Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
|
| 997 |
</div>
|
| 998 |
+
|
| 999 |
<div class="flex-container">
|
| 1000 |
<div class="flex-item">
|
| 1001 |
<div class="section">
|
|
|
|
| 1011 |
</form>
|
| 1012 |
</div>
|
| 1013 |
</details>
|
| 1014 |
+
|
| 1015 |
<h3>Существующие категории:</h3>
|
| 1016 |
{% if categories %}
|
| 1017 |
<div class="item-list">
|
|
|
|
| 1031 |
{% endif %}
|
| 1032 |
</div>
|
| 1033 |
</div>
|
| 1034 |
+
|
| 1035 |
<div class="flex-item">
|
| 1036 |
<div class="section">
|
| 1037 |
<h2><i class="fas fa-info-circle"></i> Информация</h2>
|
|
|
|
| 1040 |
</div>
|
| 1041 |
</div>
|
| 1042 |
</div>
|
| 1043 |
+
|
| 1044 |
<div class="section">
|
| 1045 |
<h2><i class="fas fa-box-open"></i> Управление товарами</h2>
|
| 1046 |
<details>
|
|
|
|
| 1085 |
</form>
|
| 1086 |
</div>
|
| 1087 |
</details>
|
| 1088 |
+
|
| 1089 |
<h3>Список товаров:</h3>
|
| 1090 |
{% if products %}
|
| 1091 |
<div class="item-list">
|
|
|
|
| 1102 |
{% endif %}
|
| 1103 |
</div>
|
| 1104 |
<div style="flex-grow: 1;">
|
| 1105 |
+
<h3 style="margin-top: 0; margin-bottom: 5px; color: #333;">
|
| 1106 |
{{ product['name'] }}
|
| 1107 |
{% if product.get('in_stock', True) %}
|
| 1108 |
<span class="status-indicator in-stock">В наличии</span>
|
|
|
|
| 1119 |
{% set colors = product.get('colors', []) %}
|
| 1120 |
<p><strong>Цвета/Вар-ты:</strong> {{ colors|select('ne', '')|join(', ') if colors|select('ne', '')|list|length > 0 else 'Нет' }}</p>
|
| 1121 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 1122 |
+
<p style="font-size: 0.8rem; color: #999;">(Всего фото: {{ product['photos']|length }})</p>
|
| 1123 |
{% endif %}
|
| 1124 |
</div>
|
| 1125 |
</div>
|
| 1126 |
+
|
| 1127 |
<div class="item-actions">
|
| 1128 |
<button type="button" class="button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
|
| 1129 |
<form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
|
|
|
|
| 1132 |
<button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
|
| 1133 |
</form>
|
| 1134 |
</div>
|
| 1135 |
+
|
| 1136 |
<div id="edit-form-{{ loop.index0 }}" class="edit-form-container">
|
| 1137 |
<h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
|
| 1138 |
<form method="POST" enctype="multipart/form-data">
|
|
|
|
| 1201 |
<p>Товаров пока нет.</p>
|
| 1202 |
{% endif %}
|
| 1203 |
</div>
|
| 1204 |
+
|
| 1205 |
</div>
|
| 1206 |
+
|
| 1207 |
<script>
|
| 1208 |
function toggleEditForm(formId) {
|
| 1209 |
const formContainer = document.getElementById(formId);
|
|
|
|
| 1211 |
formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none';
|
| 1212 |
}
|
| 1213 |
}
|
| 1214 |
+
|
| 1215 |
function addColorInput(containerId) {
|
| 1216 |
const container = document.getElementById(containerId);
|
| 1217 |
if (container) {
|
|
|
|
| 1228 |
}
|
| 1229 |
}
|
| 1230 |
}
|
| 1231 |
+
|
| 1232 |
function removeColorInput(button) {
|
| 1233 |
const group = button.closest('.color-input-group');
|
| 1234 |
if (group) {
|
|
|
|
| 1251 |
</body>
|
| 1252 |
</html>
|
| 1253 |
'''
|
| 1254 |
+
|
| 1255 |
+
|
| 1256 |
@app.route('/')
|
| 1257 |
def catalog():
|
| 1258 |
data = load_data()
|
| 1259 |
all_products = data.get('products', [])
|
| 1260 |
categories = sorted(data.get('categories', []))
|
| 1261 |
+
|
| 1262 |
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 1263 |
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 1264 |
+
|
| 1265 |
return render_template_string(
|
| 1266 |
CATALOG_TEMPLATE,
|
| 1267 |
products=products_sorted,
|
|
|
|
| 1270 |
store_address=STORE_ADDRESS,
|
| 1271 |
currency_code=CURRENCY_CODE
|
| 1272 |
)
|
| 1273 |
+
|
| 1274 |
@app.route('/product/<int:index>')
|
| 1275 |
def product_detail(index):
|
| 1276 |
data = load_data()
|
| 1277 |
all_products = data.get('products', [])
|
| 1278 |
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 1279 |
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 1280 |
+
|
| 1281 |
try:
|
| 1282 |
product = products_sorted[index]
|
| 1283 |
except IndexError:
|
| 1284 |
logging.warning(f"Attempted access to non-existent or out-of-stock product with index {index}")
|
| 1285 |
return "Товар не найден или отсутствует в наличии.", 404
|
| 1286 |
+
|
| 1287 |
return render_template_string(
|
| 1288 |
PRODUCT_DETAIL_TEMPLATE,
|
| 1289 |
product=product,
|
| 1290 |
repo_id=REPO_ID,
|
| 1291 |
currency_code=CURRENCY_CODE
|
| 1292 |
)
|
| 1293 |
+
|
| 1294 |
@app.route('/create_order', methods=['POST'])
|
| 1295 |
def create_order():
|
| 1296 |
order_data = request.get_json()
|
| 1297 |
+
|
| 1298 |
if not order_data or 'cart' not in order_data or not order_data['cart']:
|
| 1299 |
logging.warning("Create order request missing cart data or cart is empty.")
|
| 1300 |
return jsonify({"error": "Корзина пуста или не передана."}), 400
|
| 1301 |
+
|
| 1302 |
cart_items = order_data['cart']
|
| 1303 |
+
|
| 1304 |
total_price = 0
|
| 1305 |
processed_cart = []
|
| 1306 |
for item in cart_items:
|
|
|
|
| 1324 |
except (ValueError, TypeError) as e:
|
| 1325 |
logging.error(f"Invalid price/quantity in cart item: {item}. Error: {e}")
|
| 1326 |
return jsonify({"error": "Неверная цена или количество в товаре."}), 400
|
| 1327 |
+
|
| 1328 |
order_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
| 1329 |
order_timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 1330 |
+
|
| 1331 |
new_order = {
|
| 1332 |
"id": order_id,
|
| 1333 |
"created_at": order_timestamp,
|
|
|
|
| 1336 |
"user_info": None,
|
| 1337 |
"status": "new"
|
| 1338 |
}
|
| 1339 |
+
|
| 1340 |
try:
|
| 1341 |
data = load_data()
|
| 1342 |
if 'orders' not in data or not isinstance(data.get('orders'), dict):
|
| 1343 |
data['orders'] = {}
|
| 1344 |
+
|
| 1345 |
data['orders'][order_id] = new_order
|
| 1346 |
save_data(data)
|
| 1347 |
logging.info(f"Order {order_id} created successfully (anonymously).")
|
| 1348 |
return jsonify({"order_id": order_id}), 201
|
| 1349 |
+
|
| 1350 |
except Exception as e:
|
| 1351 |
logging.error(f"Failed to save order {order_id}: {e}", exc_info=True)
|
| 1352 |
return jsonify({"error": "Ошибка серве��а при сохранении заказа."}), 500
|
| 1353 |
+
|
| 1354 |
+
|
| 1355 |
@app.route('/order/<order_id>')
|
| 1356 |
def view_order(order_id):
|
| 1357 |
data = load_data()
|
| 1358 |
order = data.get('orders', {}).get(order_id)
|
| 1359 |
+
|
| 1360 |
if order:
|
| 1361 |
logging.info(f"Displaying order {order_id}")
|
| 1362 |
else:
|
| 1363 |
logging.warning(f"Order {order_id} not found.")
|
| 1364 |
+
|
| 1365 |
return render_template_string(ORDER_TEMPLATE,
|
| 1366 |
order=order,
|
| 1367 |
repo_id=REPO_ID,
|
| 1368 |
+
currency_code=CURRENCY_CODE)
|
| 1369 |
+
|
| 1370 |
+
|
| 1371 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1372 |
def admin():
|
| 1373 |
data = load_data()
|
|
|
|
| 1375 |
categories = data.get('categories', [])
|
| 1376 |
if 'orders' not in data or not isinstance(data.get('orders'), dict):
|
| 1377 |
data['orders'] = {}
|
| 1378 |
+
|
| 1379 |
if request.method == 'POST':
|
| 1380 |
action = request.form.get('action')
|
| 1381 |
logging.info(f"Admin action received: {action}")
|
| 1382 |
+
|
| 1383 |
try:
|
| 1384 |
if action == 'add_category':
|
| 1385 |
category_name = request.form.get('category_name', '').strip()
|
|
|
|
| 1395 |
else:
|
| 1396 |
logging.warning(f"Category '{category_name}' already exists.")
|
| 1397 |
flash(f"Категория '{category_name}' уже существует.", 'error')
|
| 1398 |
+
|
| 1399 |
elif action == 'delete_category':
|
| 1400 |
category_to_delete = request.form.get('category_name')
|
| 1401 |
if category_to_delete and category_to_delete in categories:
|
|
|
|
| 1413 |
else:
|
| 1414 |
logging.warning(f"Attempted to delete non-existent or empty category: {category_to_delete}")
|
| 1415 |
flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error')
|
| 1416 |
+
|
| 1417 |
elif action == 'add_product':
|
| 1418 |
name = request.form.get('name', '').strip()
|
| 1419 |
price_str = request.form.get('price', '').replace(',', '.')
|
|
|
|
| 1423 |
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1424 |
in_stock = 'in_stock' in request.form
|
| 1425 |
is_top = 'is_top' in request.form
|
| 1426 |
+
|
| 1427 |
if not name or not price_str:
|
| 1428 |
flash("Название и цена товара обязательны.", 'error')
|
| 1429 |
return redirect(url_for('admin'))
|
| 1430 |
+
|
| 1431 |
try:
|
| 1432 |
price = round(float(price_str), 2)
|
| 1433 |
if price < 0: price = 0
|
| 1434 |
except ValueError:
|
| 1435 |
flash("Неверный формат цены.", 'error')
|
| 1436 |
return redirect(url_for('admin'))
|
| 1437 |
+
|
| 1438 |
photos_list = []
|
| 1439 |
if photos_files and HF_TOKEN_WRITE:
|
| 1440 |
uploads_dir = 'uploads_temp'
|
|
|
|
| 1454 |
logging.warning(f"Skipping non-image file upload: {photo.filename}")
|
| 1455 |
flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning")
|
| 1456 |
continue
|
| 1457 |
+
|
| 1458 |
safe_name = secure_filename(name.replace(' ', '_'))[:50]
|
| 1459 |
photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1460 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
|
|
|
| 1487 |
logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}")
|
| 1488 |
elif not HF_TOKEN_WRITE and photos_files and any(f.filename for f in photos_files):
|
| 1489 |
flash("HF_TOKEN (write) не настроен. Фотографии не были загружены.", "warning")
|
| 1490 |
+
|
| 1491 |
+
|
| 1492 |
new_product = {
|
| 1493 |
'name': name, 'price': price, 'description': description,
|
| 1494 |
'category': category if category in categories else 'Без категории',
|
|
|
|
| 1500 |
save_data(data)
|
| 1501 |
logging.info(f"Product '{name}' added.")
|
| 1502 |
flash(f"Товар '{name}' успешно добавлен.", 'success')
|
| 1503 |
+
|
| 1504 |
elif action == 'edit_product':
|
| 1505 |
index_str = request.form.get('index')
|
| 1506 |
if index_str is None:
|
| 1507 |
flash("Ошибка редактирования: индекс товара не передан.", 'error')
|
| 1508 |
return redirect(url_for('admin'))
|
| 1509 |
+
|
| 1510 |
try:
|
| 1511 |
index = int(index_str)
|
| 1512 |
if not (0 <= index < len(products)):
|
| 1513 |
raise IndexError("Product index out of range")
|
| 1514 |
product_to_edit = products[index]
|
| 1515 |
original_name = product_to_edit.get('name', 'N/A')
|
| 1516 |
+
|
| 1517 |
except (ValueError, IndexError):
|
| 1518 |
flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
|
| 1519 |
logging.error(f"Invalid index '{index_str}' for editing. Product list length: {len(products)}")
|
| 1520 |
return redirect(url_for('admin'))
|
| 1521 |
+
|
| 1522 |
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
|
| 1523 |
price_str = request.form.get('price', str(product_to_edit['price'])).replace(',', '.')
|
| 1524 |
product_to_edit['description'] = request.form.get('description', product_to_edit.get('description', '')).strip()
|
|
|
|
| 1527 |
product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1528 |
product_to_edit['in_stock'] = 'in_stock' in request.form
|
| 1529 |
product_to_edit['is_top'] = 'is_top' in request.form
|
| 1530 |
+
|
| 1531 |
try:
|
| 1532 |
price = round(float(price_str), 2)
|
| 1533 |
if price < 0: price = 0
|
|
|
|
| 1535 |
except ValueError:
|
| 1536 |
logging.warning(f"Invalid price format '{price_str}' during edit of product {original_name}. Price not changed.")
|
| 1537 |
flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
|
| 1538 |
+
|
| 1539 |
photos_files = request.files.getlist('photos')
|
| 1540 |
if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
|
| 1541 |
uploads_dir = 'uploads_temp'
|
|
|
|
| 1557 |
logging.warning(f"Skipping non-image file upload during edit: {photo.filename}")
|
| 1558 |
flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning")
|
| 1559 |
continue
|
| 1560 |
+
|
| 1561 |
safe_name = secure_filename(product_to_edit['name'].replace(' ', '_'))[:50]
|
| 1562 |
photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1563 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
|
|
|
| 1581 |
os.rmdir(uploads_dir)
|
| 1582 |
except OSError as e:
|
| 1583 |
logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}")
|
| 1584 |
+
|
| 1585 |
if new_photos_list:
|
| 1586 |
logging.info(f"New photo list for product {product_to_edit['name']} generated.")
|
| 1587 |
old_photos = product_to_edit.get('photos', [])
|
|
|
|
| 1605 |
flash("Не удалось загрузить новые фотографии (возможно, неверный формат).", "error")
|
| 1606 |
elif not HF_TOKEN_WRITE and photos_files and any(f.filename for f in photos_files):
|
| 1607 |
flash("HF_TOKEN (write) не настроен. Фотографии не были обновлены.", "warning")
|
| 1608 |
+
|
| 1609 |
products[index] = product_to_edit
|
| 1610 |
data['products'] = products
|
| 1611 |
save_data(data)
|
| 1612 |
logging.info(f"Product '{original_name}' (index {index}) updated to '{product_to_edit['name']}'.")
|
| 1613 |
flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
|
| 1614 |
+
|
| 1615 |
elif action == 'delete_product':
|
| 1616 |
index_str = request.form.get('index')
|
| 1617 |
if index_str is None:
|
|
|
|
| 1622 |
if not (0 <= index < len(products)): raise IndexError("Product index out of range")
|
| 1623 |
deleted_product = products.pop(index)
|
| 1624 |
product_name = deleted_product.get('name', 'N/A')
|
| 1625 |
+
|
| 1626 |
photos_to_delete = deleted_product.get('photos', [])
|
| 1627 |
if photos_to_delete and HF_TOKEN_WRITE:
|
| 1628 |
logging.info(f"Attempting to delete photos for product '{product_name}' from HF: {photos_to_delete}")
|
|
|
|
| 1642 |
elif photos_to_delete and not HF_TOKEN_WRITE:
|
| 1643 |
logging.warning(f"HF_TOKEN (write) not set. Cannot delete photos {photos_to_delete} for deleted product '{product_name}'.")
|
| 1644 |
flash(f"Товар '{product_name}' удален локально, но фото не удалены с сервера (токен не задан).", "warning")
|
| 1645 |
+
|
| 1646 |
+
|
| 1647 |
data['products'] = products
|
| 1648 |
save_data(data)
|
| 1649 |
logging.info(f"Product '{product_name}' (original index {index}) deleted.")
|
|
|
|
| 1651 |
except (ValueError, IndexError):
|
| 1652 |
flash(f"Ошибка удаления: неверный индекс товара '{index_str}'.", 'error')
|
| 1653 |
logging.error(f"Invalid index '{index_str}' for deletion. Product list length: {len(products)}")
|
| 1654 |
+
|
| 1655 |
else:
|
| 1656 |
logging.warning(f"Received unknown admin action: {action}")
|
| 1657 |
flash(f"Неизвестное действие: {action}", 'warning')
|
| 1658 |
+
|
| 1659 |
return redirect(url_for('admin'))
|
| 1660 |
+
|
| 1661 |
except Exception as e:
|
| 1662 |
+
logging.error(f"Произошла внутренняя ошибка при выполнении действия '{action}': {e}", exc_info=True)
|
| 1663 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1664 |
return redirect(url_for('admin'))
|
| 1665 |
+
|
| 1666 |
current_data = load_data()
|
| 1667 |
display_products = sorted(current_data.get('products', []), key=lambda p: p.get('name', '').lower())
|
| 1668 |
display_categories = sorted(current_data.get('categories', []))
|
| 1669 |
+
|
| 1670 |
return render_template_string(
|
| 1671 |
ADMIN_TEMPLATE,
|
| 1672 |
products=display_products,
|
|
|
|
| 1674 |
repo_id=REPO_ID,
|
| 1675 |
currency_code=CURRENCY_CODE
|
| 1676 |
)
|
| 1677 |
+
|
| 1678 |
@app.route('/force_upload', methods=['POST'])
|
| 1679 |
def force_upload():
|
| 1680 |
logging.info("Forcing upload to Hugging Face...")
|
|
|
|
| 1685 |
logging.error(f"Error during forced upload: {e}", exc_info=True)
|
| 1686 |
flash(f"Ошибка при загрузке на Hugging Face: {e}", 'error')
|
| 1687 |
return redirect(url_for('admin'))
|
| 1688 |
+
|
| 1689 |
@app.route('/force_download', methods=['POST'])
|
| 1690 |
def force_download():
|
| 1691 |
logging.info("Forcing download from Hugging Face...")
|
|
|
|
| 1699 |
logging.error(f"Error during forced download: {e}", exc_info=True)
|
| 1700 |
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
|
| 1701 |
return redirect(url_for('admin'))
|
| 1702 |
+
|
| 1703 |
+
|
| 1704 |
if __name__ == '__main__':
|
| 1705 |
logging.info("Application starting up. Performing initial data load/download...")
|
| 1706 |
download_db_from_hf()
|
| 1707 |
load_data()
|
| 1708 |
logging.info("Initial data load complete.")
|
| 1709 |
+
|
| 1710 |
if HF_TOKEN_WRITE:
|
| 1711 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 1712 |
backup_thread.start()
|
| 1713 |
logging.info("Periodic backup thread started.")
|
| 1714 |
else:
|
| 1715 |
logging.warning("Periodic backup will NOT run (HF_TOKEN for writing not set).")
|
| 1716 |
+
|
| 1717 |
port = int(os.environ.get('PORT', 7860))
|
| 1718 |
logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
|
| 1719 |
app.run(debug=False, host='0.0.0.0', port=port)
|