Update app.py
Browse files
app.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
|
| 2 |
-
|
| 3 |
-
from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
import logging
|
|
@@ -40,18 +39,15 @@ def load_data():
|
|
| 40 |
logging.info(f"Данные успешно загружены из {DATA_FILE}")
|
| 41 |
if not isinstance(data, dict):
|
| 42 |
logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.")
|
| 43 |
-
|
| 44 |
-
|
| 45 |
if 'products' not in data:
|
| 46 |
data['products'] = []
|
| 47 |
if 'categories' not in data:
|
| 48 |
data['categories'] = []
|
| 49 |
-
|
| 50 |
-
# Ensure new fields exist with defaults
|
| 51 |
for product in data['products']:
|
| 52 |
-
product.setdefault('
|
| 53 |
product.setdefault('in_stock', True)
|
| 54 |
-
|
| 55 |
return data
|
| 56 |
except FileNotFoundError:
|
| 57 |
logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачать с HF.")
|
|
@@ -67,9 +63,8 @@ def load_data():
|
|
| 67 |
if not isinstance(data, dict): return {'products': [], 'categories': []}
|
| 68 |
if 'products' not in data: data['products'] = []
|
| 69 |
if 'categories' not in data: data['categories'] = []
|
| 70 |
-
# Ensure new fields exist with defaults after download
|
| 71 |
for product in data['products']:
|
| 72 |
-
product.setdefault('
|
| 73 |
product.setdefault('in_stock', True)
|
| 74 |
return data
|
| 75 |
except (FileNotFoundError, RepositoryNotFoundError) as e:
|
|
@@ -93,12 +88,6 @@ def load_data():
|
|
| 93 |
|
| 94 |
def save_data(data):
|
| 95 |
try:
|
| 96 |
-
# Ensure new fields exist before saving
|
| 97 |
-
if 'products' in data:
|
| 98 |
-
for product in data['products']:
|
| 99 |
-
product.setdefault('is_top', False)
|
| 100 |
-
product.setdefault('in_stock', True)
|
| 101 |
-
|
| 102 |
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 103 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 104 |
logging.info(f"Данные успешно сохранены в {DATA_FILE}")
|
|
@@ -201,7 +190,7 @@ def download_db_from_hf(specific_file=None):
|
|
| 201 |
logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.")
|
| 202 |
break
|
| 203 |
except Exception as e:
|
| 204 |
-
if "404" in str(e) or isinstance(e, FileNotFoundError)
|
| 205 |
logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.")
|
| 206 |
else:
|
| 207 |
logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True)
|
|
@@ -221,6 +210,9 @@ def periodic_backup():
|
|
| 221 |
upload_db_to_hf()
|
| 222 |
logging.info("Периодическое резервное копирование завершено.")
|
| 223 |
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
@app.route('/')
|
| 226 |
def catalog():
|
|
@@ -229,14 +221,8 @@ def catalog():
|
|
| 229 |
categories = data.get('categories', [])
|
| 230 |
is_authenticated = 'user' in session
|
| 231 |
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
# Sort products: top products first, then alphabetically
|
| 236 |
-
sorted_products = sorted(
|
| 237 |
-
available_products,
|
| 238 |
-
key=lambda p: (not p.get('is_top', False), p.get('name', '').lower())
|
| 239 |
-
)
|
| 240 |
|
| 241 |
catalog_html = '''
|
| 242 |
<!DOCTYPE html>
|
|
@@ -245,6 +231,7 @@ def catalog():
|
|
| 245 |
<meta charset="UTF-8">
|
| 246 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 247 |
<title>Soola Cosmetics - Каталог</title>
|
|
|
|
| 248 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 249 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 250 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
|
|
@@ -285,10 +272,9 @@ def catalog():
|
|
| 285 |
body.dark-mode .product { background: #253f37; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #2c4a41; }
|
| 286 |
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
|
| 287 |
body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
|
| 288 |
-
.product-image { width: 100%; aspect-ratio: 1 / 1; background-color: #fff; border-radius: 10px 10px 0 0; overflow: hidden; display: flex; justify-content: center; align-items: center; margin-bottom: 0;
|
| 289 |
.product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; }
|
| 290 |
.product-image img:hover { transform: scale(1.08); }
|
| 291 |
-
.top-product-badge { position: absolute; top: 10px; left: 10px; background-color: #ffc107; color: #333; padding: 3px 8px; font-size: 0.75rem; font-weight: bold; border-radius: 5px; z-index: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
|
| 292 |
.product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
|
| 293 |
.product h2 { font-size: 1.1rem; font-weight: 600; margin: 0 0 8px 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #2d332f; }
|
| 294 |
body.dark-mode .product h2 { color: #c8d8d3; }
|
|
@@ -300,6 +286,7 @@ def catalog():
|
|
| 300 |
.product-button { display: block; width: 100%; padding: 10px; border: none; border-radius: 8px; background-color: #1C6758; color: white; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); text-align: center; text-decoration: none; }
|
| 301 |
.product-button:hover { background-color: #164B41; box-shadow: 0 4px 15px rgba(22, 75, 65, 0.4); transform: translateY(-2px); }
|
| 302 |
.product-button i { margin-right: 5px; }
|
|
|
|
| 303 |
|
| 304 |
.add-to-cart { background-color: #38a169; }
|
| 305 |
.add-to-cart:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
|
@@ -358,7 +345,7 @@ def catalog():
|
|
| 358 |
<a href="{{ url_for('login') }}">Войти</a>
|
| 359 |
{% endif %}
|
| 360 |
</div>
|
| 361 |
-
<button class="theme-toggle" aria-label="Переключить тему">
|
| 362 |
<i class="fas fa-moon"></i>
|
| 363 |
</button>
|
| 364 |
</div>
|
|
@@ -378,14 +365,12 @@ def catalog():
|
|
| 378 |
|
| 379 |
<div class="products-grid" id="products-grid">
|
| 380 |
{% for product in products %}
|
| 381 |
-
<div class="product"
|
| 382 |
data-name="{{ product['name']|lower }}"
|
| 383 |
data-description="{{ product.get('description', '')|lower }}"
|
| 384 |
-
data-category="{{ product.get('category', 'Без категории') }}"
|
|
|
|
| 385 |
<div class="product-image">
|
| 386 |
-
{% if product.get('is_top') %}
|
| 387 |
-
<span class="top-product-badge" title="Популярный товар"><i class="fas fa-star"></i> Топ</span>
|
| 388 |
-
{% endif %}
|
| 389 |
{% if product.get('photos') and product['photos']|length > 0 %}
|
| 390 |
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
|
| 391 |
alt="{{ product['name'] }}"
|
|
@@ -395,7 +380,7 @@ def catalog():
|
|
| 395 |
{% endif %}
|
| 396 |
</div>
|
| 397 |
<div class="product-info">
|
| 398 |
-
<h2>{{ product['name'] }}</h2>
|
| 399 |
{% if is_authenticated %}
|
| 400 |
<div class="product-price">{{ "%.2f"|format(product['price']) }} {{ currency_code }}</div>
|
| 401 |
{% else %}
|
|
@@ -404,9 +389,9 @@ def catalog():
|
|
| 404 |
<p class="product-description">{{ product.get('description', '')[:50] }}{% if product.get('description', '')|length > 50 %}...{% endif %}</p>
|
| 405 |
</div>
|
| 406 |
<div class="product-actions">
|
| 407 |
-
<button class="product-button"
|
| 408 |
{% if is_authenticated %}
|
| 409 |
-
<button class="product-button add-to-cart"
|
| 410 |
<i class="fas fa-cart-plus"></i> В корзину
|
| 411 |
</button>
|
| 412 |
{% endif %}
|
|
@@ -414,8 +399,9 @@ def catalog():
|
|
| 414 |
</div>
|
| 415 |
{% endfor %}
|
| 416 |
{% if not products %}
|
| 417 |
-
<p class="no-results-message">Товары пока не добавлены или
|
| 418 |
{% endif %}
|
|
|
|
| 419 |
</div>
|
| 420 |
</div>
|
| 421 |
|
|
@@ -466,13 +452,15 @@ def catalog():
|
|
| 466 |
|
| 467 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
|
| 468 |
<script>
|
| 469 |
-
|
|
|
|
| 470 |
const repoId = '{{ repo_id }}';
|
| 471 |
const currencyCode = '{{ currency_code }}';
|
| 472 |
const isAuthenticated = {{ is_authenticated|tojson }};
|
| 473 |
let selectedProductIndex = null;
|
| 474 |
let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
|
| 475 |
|
|
|
|
| 476 |
function toggleTheme() {
|
| 477 |
document.body.classList.toggle('dark-mode');
|
| 478 |
const icon = document.querySelector('.theme-toggle i');
|
|
@@ -488,10 +476,13 @@ def catalog():
|
|
| 488 |
document.body.classList.add('dark-mode');
|
| 489 |
const icon = document.querySelector('.theme-toggle i');
|
| 490 |
if (icon) icon.classList.replace('fa-moon', 'fa-sun');
|
|
|
|
|
|
|
|
|
|
| 491 |
}
|
| 492 |
}
|
| 493 |
|
| 494 |
-
|
| 495 |
const storedUser = localStorage.getItem('soolaUser');
|
| 496 |
if (storedUser && !isAuthenticated) {
|
| 497 |
console.log('Attempting auto-login for:', storedUser);
|
|
@@ -506,18 +497,19 @@ def catalog():
|
|
| 506 |
window.location.reload();
|
| 507 |
} else {
|
| 508 |
response.text().then(text => console.log(`Auto-login failed: ${response.status} ${text}`));
|
| 509 |
-
localStorage.removeItem('soolaUser');
|
| 510 |
}
|
| 511 |
})
|
| 512 |
.catch(error => {
|
| 513 |
console.error('Auto-login fetch error:', error);
|
| 514 |
-
localStorage.removeItem('soolaUser');
|
| 515 |
});
|
| 516 |
}
|
| 517 |
}
|
| 518 |
|
| 519 |
-
function openModal(
|
| 520 |
-
|
|
|
|
| 521 |
const modal = document.getElementById('productModal');
|
| 522 |
if (modal) {
|
| 523 |
modal.style.display = "block";
|
|
@@ -530,6 +522,7 @@ def catalog():
|
|
| 530 |
if (modal) {
|
| 531 |
modal.style.display = "none";
|
| 532 |
}
|
|
|
|
| 533 |
const anyModalOpen = document.querySelector('.modal[style*="display: block"]');
|
| 534 |
if (!anyModalOpen) {
|
| 535 |
document.body.style.overflow = 'auto';
|
|
@@ -540,7 +533,7 @@ def catalog():
|
|
| 540 |
const modalContent = document.getElementById('modalContent');
|
| 541 |
if (!modalContent) return;
|
| 542 |
modalContent.innerHTML = '<p style="text-align:center; padding: 40px;">Загрузка...</p>';
|
| 543 |
-
//
|
| 544 |
fetch('/product/' + index)
|
| 545 |
.then(response => {
|
| 546 |
if (!response.ok) throw new Error(`Ошибка ${response.status}: ${response.statusText}`);
|
|
@@ -562,7 +555,7 @@ def catalog():
|
|
| 562 |
new Swiper(swiperContainer, {
|
| 563 |
slidesPerView: 1,
|
| 564 |
spaceBetween: 20,
|
| 565 |
-
loop: true,
|
| 566 |
grabCursor: true,
|
| 567 |
pagination: { el: '.swiper-pagination', clickable: true },
|
| 568 |
navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
|
|
@@ -572,17 +565,17 @@ def catalog():
|
|
| 572 |
}
|
| 573 |
}
|
| 574 |
|
| 575 |
-
|
| 576 |
if (!isAuthenticated) {
|
| 577 |
alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
|
| 578 |
window.location.href = '/login';
|
| 579 |
return;
|
| 580 |
}
|
| 581 |
-
selectedProductIndex = index
|
| 582 |
-
|
| 583 |
-
|
| 584 |
if (!product) {
|
| 585 |
-
console.error("Product not found for index:",
|
| 586 |
alert("Ошибка: товар не найден.");
|
| 587 |
return;
|
| 588 |
}
|
|
@@ -603,6 +596,11 @@ def catalog():
|
|
| 603 |
colorSelect.style.display = 'block';
|
| 604 |
if(colorLabel) colorLabel.style.display = 'block';
|
| 605 |
} else {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
colorSelect.style.display = 'none';
|
| 607 |
if(colorLabel) colorLabel.style.display = 'none';
|
| 608 |
}
|
|
@@ -615,12 +613,14 @@ def catalog():
|
|
| 615 |
}
|
| 616 |
}
|
| 617 |
|
|
|
|
| 618 |
function confirmAddToCart() {
|
| 619 |
if (selectedProductIndex === null) return;
|
| 620 |
|
| 621 |
const quantityInput = document.getElementById('quantityInput');
|
| 622 |
const quantity = parseInt(quantityInput.value);
|
| 623 |
const colorSelect = document.getElementById('colorSelect');
|
|
|
|
| 624 |
const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A';
|
| 625 |
|
| 626 |
if (isNaN(quantity) || quantity <= 0) {
|
|
@@ -629,20 +629,21 @@ def catalog():
|
|
| 629 |
return;
|
| 630 |
}
|
| 631 |
|
| 632 |
-
const product = products[selectedProductIndex];
|
| 633 |
if (!product) {
|
| 634 |
alert("Ошибка добавления: товар не найден.");
|
| 635 |
return;
|
| 636 |
}
|
| 637 |
|
| 638 |
-
|
|
|
|
| 639 |
const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
|
| 640 |
|
| 641 |
if (existingItemIndex > -1) {
|
| 642 |
cart[existingItemIndex].quantity += quantity;
|
| 643 |
} else {
|
| 644 |
cart.push({
|
| 645 |
-
id: cartItemId,
|
| 646 |
name: product.name,
|
| 647 |
price: product.price,
|
| 648 |
photo: product.photos && product.photos.length > 0 ? product.photos[0] : null,
|
|
@@ -655,8 +656,10 @@ def catalog():
|
|
| 655 |
closeModal('quantityModal');
|
| 656 |
updateCartButton();
|
| 657 |
showNotification(`${product.name} добавлен в корзину!`);
|
|
|
|
| 658 |
}
|
| 659 |
|
|
|
|
| 660 |
function updateCartButton() {
|
| 661 |
const cartCountElement = document.getElementById('cart-count');
|
| 662 |
const cartButton = document.getElementById('cart-button');
|
|
@@ -665,7 +668,7 @@ def catalog():
|
|
| 665 |
let totalItems = 0;
|
| 666 |
cart.forEach(item => { totalItems += item.quantity; });
|
| 667 |
|
| 668 |
-
if (totalItems > 0) {
|
| 669 |
cartCountElement.textContent = totalItems;
|
| 670 |
cartButton.style.display = 'flex';
|
| 671 |
} else {
|
|
@@ -675,6 +678,9 @@ def catalog():
|
|
| 675 |
}
|
| 676 |
|
| 677 |
function openCartModal() {
|
|
|
|
|
|
|
|
|
|
| 678 |
const cartContent = document.getElementById('cartContent');
|
| 679 |
const cartTotalElement = document.getElementById('cartTotal');
|
| 680 |
if (!cartContent || !cartTotalElement) return;
|
|
@@ -717,16 +723,16 @@ def catalog():
|
|
| 717 |
function removeFromCart(itemId) {
|
| 718 |
cart = cart.filter(item => item.id !== itemId);
|
| 719 |
localStorage.setItem('soolaCart', JSON.stringify(cart));
|
| 720 |
-
openCartModal();
|
| 721 |
-
updateCartButton();
|
| 722 |
}
|
| 723 |
|
| 724 |
function clearCart() {
|
| 725 |
if (confirm("Вы уверены, что хотите очистить корзину?")) {
|
| 726 |
cart = [];
|
| 727 |
localStorage.removeItem('soolaCart');
|
| 728 |
-
openCartModal();
|
| 729 |
-
updateCartButton();
|
| 730 |
}
|
| 731 |
}
|
| 732 |
|
|
@@ -751,7 +757,11 @@ def catalog():
|
|
| 751 |
orderText += `*Итого: ${total.toFixed(2)} ${currencyCode}*\n\n`;
|
| 752 |
orderText += "--- Заказчик ---\n";
|
| 753 |
|
| 754 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
if (userInfo && userInfo.login) {
|
| 756 |
orderText += `Имя: ${userInfo.first_name || ''} ${userInfo.last_name || ''}\n`;
|
| 757 |
orderText += `Логин: ${userInfo.login}\n`;
|
|
@@ -766,23 +776,20 @@ def catalog():
|
|
| 766 |
const dateTimeString = now.toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'medium'});
|
| 767 |
orderText += `\nДата заказа: ${dateTimeString}`;
|
| 768 |
|
| 769 |
-
const whatsappNumber = "996997703090";
|
| 770 |
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
|
| 771 |
window.open(whatsappUrl, '_blank');
|
| 772 |
}
|
| 773 |
|
| 774 |
-
|
| 775 |
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
|
| 776 |
const activeCategoryButton = document.querySelector('.category-filter.active');
|
| 777 |
const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all';
|
| 778 |
const grid = document.getElementById('products-grid');
|
|
|
|
| 779 |
let visibleProducts = 0;
|
| 780 |
|
| 781 |
-
|
| 782 |
-
if (existingNoResults) existingNoResults.remove();
|
| 783 |
-
|
| 784 |
-
// Iterate over the products displayed (which are already filtered by stock)
|
| 785 |
-
document.querySelectorAll('.products-grid .product').forEach(productElement => {
|
| 786 |
const name = productElement.getAttribute('data-name');
|
| 787 |
const description = productElement.getAttribute('data-description');
|
| 788 |
const category = productElement.getAttribute('data-category');
|
|
@@ -791,31 +798,29 @@ def catalog():
|
|
| 791 |
const matchesCategory = activeCategory === 'all' || category === activeCategory;
|
| 792 |
|
| 793 |
if (matchesSearch && matchesCategory) {
|
| 794 |
-
productElement.style.display = 'flex';
|
| 795 |
visibleProducts++;
|
| 796 |
} else {
|
| 797 |
productElement.style.display = 'none';
|
| 798 |
}
|
| 799 |
});
|
| 800 |
|
|
|
|
| 801 |
if (visibleProducts === 0 && (searchTerm || activeCategory !== 'all')) {
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
grid.appendChild(p);
|
| 806 |
-
} else if (products.length === 0 && !grid.querySelector('.no-results-message')) {
|
| 807 |
-
const p = document.createElement('p');
|
| 808 |
-
p.className = 'no-results-message';
|
| 809 |
-
p.textContent = 'Товары пока не добавлены или нет в наличии.';
|
| 810 |
-
grid.appendChild(p);
|
| 811 |
}
|
| 812 |
-
|
|
|
|
| 813 |
|
| 814 |
function setupFilters() {
|
| 815 |
const searchInput = document.getElementById('search-input');
|
| 816 |
const categoryFilters = document.querySelectorAll('.category-filter');
|
| 817 |
|
| 818 |
-
if(searchInput)
|
|
|
|
|
|
|
| 819 |
|
| 820 |
categoryFilters.forEach(filter => {
|
| 821 |
filter.addEventListener('click', function() {
|
|
@@ -828,69 +833,52 @@ def catalog():
|
|
| 828 |
|
| 829 |
function showNotification(message, duration = 3000) {
|
| 830 |
const placeholder = document.getElementById('notification-placeholder');
|
| 831 |
-
if (!placeholder)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
|
| 833 |
const notification = document.createElement('div');
|
| 834 |
notification.className = 'notification';
|
| 835 |
notification.textContent = message;
|
| 836 |
placeholder.appendChild(notification);
|
| 837 |
|
| 838 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
|
|
|
|
| 840 |
setTimeout(() => {
|
| 841 |
notification.classList.remove('show');
|
| 842 |
-
|
|
|
|
| 843 |
}, duration);
|
| 844 |
}
|
| 845 |
|
|
|
|
| 846 |
document.addEventListener('DOMContentLoaded', () => {
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
setupFilters();
|
| 851 |
-
filterProducts(); // Initial filter application
|
| 852 |
-
|
| 853 |
-
// Attach theme toggle event listener
|
| 854 |
-
const themeToggleButton = document.querySelector('.theme-toggle');
|
| 855 |
-
if (themeToggleButton) {
|
| 856 |
-
themeToggleButton.addEventListener('click', toggleTheme);
|
| 857 |
-
}
|
| 858 |
-
|
| 859 |
-
// Attach product action listeners using event delegation
|
| 860 |
-
const productsGrid = document.getElementById('products-grid');
|
| 861 |
-
if (productsGrid) {
|
| 862 |
-
productsGrid.addEventListener('click', function(event) {
|
| 863 |
-
const button = event.target.closest('button[data-action]');
|
| 864 |
-
if (!button) return;
|
| 865 |
-
|
| 866 |
-
const action = button.dataset.action;
|
| 867 |
-
const index = parseInt(button.dataset.index, 10);
|
| 868 |
-
|
| 869 |
-
if (isNaN(index)) {
|
| 870 |
-
console.error("Invalid index found on button:", button);
|
| 871 |
-
return;
|
| 872 |
-
}
|
| 873 |
-
|
| 874 |
-
if (action === 'open-details') {
|
| 875 |
-
openModal(index);
|
| 876 |
-
} else if (action === 'open-quantity') {
|
| 877 |
-
if (isAuthenticated) {
|
| 878 |
-
openQuantityModal(index);
|
| 879 |
-
} else {
|
| 880 |
-
alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
|
| 881 |
-
window.location.href = '/login';
|
| 882 |
-
}
|
| 883 |
-
}
|
| 884 |
-
});
|
| 885 |
-
}
|
| 886 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 887 |
|
|
|
|
| 888 |
window.addEventListener('click', function(event) {
|
| 889 |
if (event.target.classList.contains('modal')) {
|
| 890 |
closeModal(event.target.id);
|
| 891 |
}
|
| 892 |
});
|
| 893 |
|
|
|
|
| 894 |
window.addEventListener('keydown', function(event) {
|
| 895 |
if (event.key === 'Escape') {
|
| 896 |
document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
|
|
@@ -898,6 +886,9 @@ def catalog():
|
|
| 898 |
});
|
| 899 |
}
|
| 900 |
});
|
|
|
|
|
|
|
|
|
|
| 901 |
});
|
| 902 |
|
| 903 |
</script>
|
|
@@ -906,7 +897,7 @@ def catalog():
|
|
| 906 |
'''
|
| 907 |
return render_template_string(
|
| 908 |
catalog_html,
|
| 909 |
-
products=
|
| 910 |
categories=categories,
|
| 911 |
repo_id=REPO_ID,
|
| 912 |
is_authenticated=is_authenticated,
|
|
@@ -919,31 +910,25 @@ def catalog():
|
|
| 919 |
@app.route('/product/<int:index>')
|
| 920 |
def product_detail(index):
|
| 921 |
data = load_data()
|
| 922 |
-
|
| 923 |
is_authenticated = 'user' in session
|
| 924 |
-
|
| 925 |
-
# Apply the same filtering and sorting as the catalog to ensure the index is correct
|
| 926 |
-
available_products = [p for p in all_products if p.get('in_stock', True)]
|
| 927 |
-
sorted_products = sorted(
|
| 928 |
-
available_products,
|
| 929 |
-
key=lambda p: (not p.get('is_top', False), p.get('name', '').lower())
|
| 930 |
-
)
|
| 931 |
-
|
| 932 |
try:
|
| 933 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
except IndexError:
|
| 935 |
-
logging.warning(f"Попытка доступа к несуществующему
|
| 936 |
-
|
| 937 |
|
| 938 |
detail_html = '''
|
| 939 |
<div style="padding: 10px;">
|
| 940 |
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #1C6758;">{{ product['name'] }}</h2>
|
| 941 |
{% if not product.get('in_stock', True) %}
|
| 942 |
-
|
| 943 |
{% endif %}
|
| 944 |
-
{% if product.get('is_top') %}
|
| 945 |
-
<p style="text-align: center; color: #ffc107; font-weight: bold; margin-bottom: 15px;"><i class="fas fa-star"></i> Популярный товар</p>
|
| 946 |
-
{% endif %}
|
| 947 |
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
|
| 948 |
<div class="swiper-wrapper">
|
| 949 |
{% if product.get('photos') and product['photos']|length > 0 %}
|
|
@@ -982,11 +967,20 @@ def product_detail(index):
|
|
| 982 |
<p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p>
|
| 983 |
{% endif %}
|
| 984 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 985 |
</div>
|
| 986 |
'''
|
|
|
|
| 987 |
return render_template_string(
|
| 988 |
detail_html,
|
| 989 |
product=product,
|
|
|
|
| 990 |
repo_id=REPO_ID,
|
| 991 |
is_authenticated=is_authenticated,
|
| 992 |
currency_code=CURRENCY_CODE
|
|
@@ -999,6 +993,7 @@ LOGIN_TEMPLATE = '''
|
|
| 999 |
<meta charset="UTF-8">
|
| 1000 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 1001 |
<title>Вход - Soola Cosmetics</title>
|
|
|
|
| 1002 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 1003 |
<style>
|
| 1004 |
body { font-family: 'Poppins', sans-serif; background: linear-gradient(135deg, #d1e7dd, #e9f5f0); display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
|
|
@@ -1056,7 +1051,7 @@ def login():
|
|
| 1056 |
}
|
| 1057 |
logging.info(f"Пользователь {login} успешно вошел в систему.")
|
| 1058 |
login_response_html = f'''
|
| 1059 |
-
<!DOCTYPE html><html><head><title>Перенаправление...</title></head><body>
|
| 1060 |
<script>
|
| 1061 |
try {{ localStorage.setItem('soolaUser', '{login}'); }} catch (e) {{ console.error("Ошибка сохранения в localStorage:", e); }}
|
| 1062 |
window.location.href = "{url_for('catalog')}";
|
|
@@ -1101,7 +1096,8 @@ def auto_login():
|
|
| 1101 |
return "OK", 200
|
| 1102 |
else:
|
| 1103 |
logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.")
|
| 1104 |
-
|
|
|
|
| 1105 |
|
| 1106 |
@app.route('/logout')
|
| 1107 |
def logout():
|
|
@@ -1111,7 +1107,7 @@ def logout():
|
|
| 1111 |
if logged_out_user:
|
| 1112 |
logging.info(f"Пользователь {logged_out_user} вышел из системы.")
|
| 1113 |
logout_response_html = '''
|
| 1114 |
-
<!DOCTYPE html><html><head><title>Выход...</title></head><body>
|
| 1115 |
<script>
|
| 1116 |
try { localStorage.removeItem('soolaUser'); } catch (e) { console.error("Ошибка удаления из localStorage:", e); }
|
| 1117 |
window.location.href = "/";
|
|
@@ -1128,16 +1124,18 @@ ADMIN_TEMPLATE = '''
|
|
| 1128 |
<meta charset="UTF-8">
|
| 1129 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 1130 |
<title>Админ-панель - Soola Cosmetics</title>
|
|
|
|
| 1131 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 1132 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 1133 |
<style>
|
| 1134 |
body { font-family: 'Poppins', sans-serif; background-color: #e9f5f0; color: #2d332f; padding: 20px; line-height: 1.6; }
|
| 1135 |
.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); }
|
| 1136 |
.header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #d1e7dd; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
|
| 1137 |
-
h1, h2, h3 { font-weight: 600; color: #1C6758; margin-bottom: 15px; }
|
| 1138 |
h1 { font-size: 1.8rem; }
|
| 1139 |
h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
|
| 1140 |
h3 { font-size: 1.2rem; color: #164B41; margin-top: 20px; }
|
|
|
|
| 1141 |
.section { margin-bottom: 30px; padding: 20px; background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; }
|
| 1142 |
form { margin-bottom: 20px; }
|
| 1143 |
label { font-weight: 500; margin-top: 10px; display: block; color: #44524c; font-size: 0.9rem;}
|
|
@@ -1146,8 +1144,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1146 |
textarea { min-height: 80px; resize: vertical; }
|
| 1147 |
input[type="file"] { padding: 8px; background-color: #f0f9f4; cursor: pointer; border: 1px solid #c4d9d1;}
|
| 1148 |
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #e0f0e9; border: 1px solid #c4d9d1; cursor: pointer; margin-right: 10px;}
|
| 1149 |
-
input[type="checkbox"] { margin-right: 5px; width: auto; vertical-align: middle;}
|
| 1150 |
-
.checkbox-label { display: inline-block; margin-top: 10px; margin-bottom: 5px; font-weight: 400; color: #2d332f; }
|
| 1151 |
button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #1C6758; 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;}
|
| 1152 |
button:hover, .button:hover { background-color: #164B41; }
|
| 1153 |
button:active, .button:active { transform: scale(0.98); }
|
|
@@ -1160,12 +1156,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1160 |
.item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #e1f0e9; }
|
| 1161 |
.item p { margin: 5px 0; font-size: 0.9rem; color: #44524c; }
|
| 1162 |
.item strong { color: #2d332f; }
|
| 1163 |
-
.item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
|
| 1164 |
-
.item-status { display: flex; gap: 15px; flex-wrap: wrap; font-size: 0.85rem; margin-top: 8px;}
|
| 1165 |
-
.status-badge { padding: 2px 8px; border-radius: 10px; font-weight: 500; }
|
| 1166 |
-
.status-top { background-color: #ffecb3; color: #6d4c41; border: 1px solid #ffe082;}
|
| 1167 |
-
.status-instock { background-color: #c8e6c9; color: #1b5e20; border: 1px solid #a5d6a7;}
|
| 1168 |
-
.status-outofstock { background-color: #ffcdd2; color: #b71c1c; border: 1px solid #ef9a9a;}
|
| 1169 |
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
|
| 1170 |
.item-actions button:not(.delete-button) { background-color: #1C6758; }
|
| 1171 |
.item-actions button:not(.delete-button):hover { background-color: #164B41; }
|
|
@@ -1192,8 +1183,11 @@ ADMIN_TEMPLATE = '''
|
|
| 1192 |
.message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
|
| 1193 |
.message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
|
| 1194 |
.message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
|
| 1195 |
-
.
|
| 1196 |
-
.
|
|
|
|
|
|
|
|
|
|
| 1197 |
</style>
|
| 1198 |
</head>
|
| 1199 |
<body>
|
|
@@ -1338,16 +1332,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1338 |
</select>
|
| 1339 |
<label for="add_photos">Фотографии (до 10 шт.):</label>
|
| 1340 |
<input type="file" id="add_photos" name="photos" accept="image/*" multiple>
|
| 1341 |
-
<div class="form-row">
|
| 1342 |
-
<div>
|
| 1343 |
-
<input type="checkbox" id="add_is_top" name="is_top">
|
| 1344 |
-
<label for="add_is_top" class="checkbox-label"> <i class="fas fa-star"></i> Топ товар (в начале каталога)</label>
|
| 1345 |
-
</div>
|
| 1346 |
-
<div>
|
| 1347 |
-
<input type="checkbox" id="add_in_stock" name="in_stock" checked>
|
| 1348 |
-
<label for="add_in_stock" class="checkbox-label"> <i class="fas fa-check-circle"></i> В наличии (отображать в каталоге)</label>
|
| 1349 |
-
</div>
|
| 1350 |
-
</div>
|
| 1351 |
<label>Цвета/Варианты (оставьте пустым, если нет):</label>
|
| 1352 |
<div id="add-color-inputs">
|
| 1353 |
<div class="color-input-group">
|
|
@@ -1356,6 +1340,16 @@ ADMIN_TEMPLATE = '''
|
|
| 1356 |
</div>
|
| 1357 |
</div>
|
| 1358 |
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1359 |
<br>
|
| 1360 |
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button>
|
| 1361 |
</form>
|
|
@@ -1363,9 +1357,9 @@ ADMIN_TEMPLATE = '''
|
|
| 1363 |
</details>
|
| 1364 |
|
| 1365 |
<h3>Список товаров:</h3>
|
| 1366 |
-
{% if
|
| 1367 |
<div class="item-list">
|
| 1368 |
-
{% for product in
|
| 1369 |
<div class="item">
|
| 1370 |
<div style="display: flex; gap: 15px; align-items: flex-start;">
|
| 1371 |
<div class="photo-preview" style="flex-shrink: 0;">
|
|
@@ -1378,24 +1372,22 @@ ADMIN_TEMPLATE = '''
|
|
| 1378 |
{% endif %}
|
| 1379 |
</div>
|
| 1380 |
<div style="flex-grow: 1;">
|
| 1381 |
-
<h3 style="margin-top: 0; margin-bottom: 5px; color: #2d332f;">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1382 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 1383 |
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 1384 |
<p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
|
| 1385 |
{% set colors = product.get('colors', []) %}
|
| 1386 |
<p><strong>Цвета/Вар-ты:</strong> {{ colors|select('ne', '')|join(', ') if colors|select('ne', '')|list|length > 0 else 'Нет' }}</p>
|
| 1387 |
-
<div class="item-status">
|
| 1388 |
-
{% if product.get('is_top') %}
|
| 1389 |
-
<span class="status-badge status-top"><i class="fas fa-star"></i> Топ товар</span>
|
| 1390 |
-
{% endif %}
|
| 1391 |
-
{% if product.get('in_stock', True) %}
|
| 1392 |
-
<span class="status-badge status-instock"><i class="fas fa-check-circle"></i> В наличии</span>
|
| 1393 |
-
{% else %}
|
| 1394 |
-
<span class="status-badge status-outofstock"><i class="fas fa-times-circle"></i> Нет в наличии</span>
|
| 1395 |
-
{% endif %}
|
| 1396 |
-
</div>
|
| 1397 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 1398 |
-
<p style="font-size: 0.8rem; color: #5e6e68;
|
| 1399 |
{% endif %}
|
| 1400 |
</div>
|
| 1401 |
</div>
|
|
@@ -1404,7 +1396,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1404 |
<button type="button" class="button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
|
| 1405 |
<form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
|
| 1406 |
<input type="hidden" name="action" value="delete_product">
|
| 1407 |
-
<input type="hidden" name="
|
| 1408 |
<button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
|
| 1409 |
</form>
|
| 1410 |
</div>
|
|
@@ -1413,7 +1405,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1413 |
<h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
|
| 1414 |
<form method="POST" enctype="multipart/form-data">
|
| 1415 |
<input type="hidden" name="action" value="edit_product">
|
| 1416 |
-
<input type="hidden" name="
|
| 1417 |
<label>Название *:</label>
|
| 1418 |
<input type="text" name="name" value="{{ product['name'] }}" required>
|
| 1419 |
<label>Цена ({{ currency_code }}) *:</label>
|
|
@@ -1437,16 +1429,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1437 |
{% endfor %}
|
| 1438 |
</div>
|
| 1439 |
{% endif %}
|
| 1440 |
-
<div class="form-row">
|
| 1441 |
-
<div>
|
| 1442 |
-
<input type="checkbox" id="edit_is_top_{{ loop.index0 }}" name="is_top" {% if product.get('is_top') %}checked{% endif %}>
|
| 1443 |
-
<label for="edit_is_top_{{ loop.index0 }}" class="checkbox-label"> <i class="fas fa-star"></i> Топ товар</label>
|
| 1444 |
-
</div>
|
| 1445 |
-
<div>
|
| 1446 |
-
<input type="checkbox" id="edit_in_stock_{{ loop.index0 }}" name="in_stock" {% if product.get('in_stock', True) %}checked{% endif %}>
|
| 1447 |
-
<label for="edit_in_stock_{{ loop.index0 }}" class="checkbox-label"> <i class="fas fa-check-circle"></i> В наличии</label>
|
| 1448 |
-
</div>
|
| 1449 |
-
</div>
|
| 1450 |
<label>Цвета/Варианты:</label>
|
| 1451 |
<div id="edit-color-inputs-{{ loop.index0 }}">
|
| 1452 |
{% set current_colors = product.get('colors', []) %}
|
|
@@ -1467,6 +1449,16 @@ ADMIN_TEMPLATE = '''
|
|
| 1467 |
{% endif %}
|
| 1468 |
</div>
|
| 1469 |
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('edit-color-inputs-{{ loop.index0 }}')"><i class="fas fa-palette"></i> Добавить поле для цвета</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1470 |
<br>
|
| 1471 |
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button>
|
| 1472 |
</form>
|
|
@@ -1509,15 +1501,16 @@ ADMIN_TEMPLATE = '''
|
|
| 1509 |
function removeColorInput(button) {
|
| 1510 |
const group = button.closest('.color-input-group');
|
| 1511 |
if (group) {
|
| 1512 |
-
|
| 1513 |
-
|
| 1514 |
-
|
| 1515 |
-
|
| 1516 |
-
|
| 1517 |
-
group.
|
| 1518 |
-
|
| 1519 |
-
|
| 1520 |
-
|
|
|
|
| 1521 |
} else {
|
| 1522 |
console.warn("Не удалось найти родительский .color-input-group для кнопки удаления");
|
| 1523 |
}
|
|
@@ -1529,16 +1522,17 @@ ADMIN_TEMPLATE = '''
|
|
| 1529 |
|
| 1530 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1531 |
def admin():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1532 |
data = load_data()
|
| 1533 |
-
|
| 1534 |
-
all_products = data.get('products', [])
|
| 1535 |
categories = data.get('categories', [])
|
| 1536 |
users = load_users()
|
| 1537 |
|
| 1538 |
-
# Sort products alphabetically for consistent admin display
|
| 1539 |
-
all_products.sort(key=lambda x: x.get('name', '').lower())
|
| 1540 |
-
|
| 1541 |
-
|
| 1542 |
if request.method == 'POST':
|
| 1543 |
action = request.form.get('action')
|
| 1544 |
logging.info(f"Admin action received: {action}")
|
|
@@ -1564,8 +1558,7 @@ def admin():
|
|
| 1564 |
if category_to_delete and category_to_delete in categories:
|
| 1565 |
categories.remove(category_to_delete)
|
| 1566 |
updated_count = 0
|
| 1567 |
-
|
| 1568 |
-
for product in data['products']:
|
| 1569 |
if product.get('category') == category_to_delete:
|
| 1570 |
product['category'] = 'Без категории'
|
| 1571 |
updated_count += 1
|
|
@@ -1584,8 +1577,8 @@ def admin():
|
|
| 1584 |
category = request.form.get('category')
|
| 1585 |
photos_files = request.files.getlist('photos')
|
| 1586 |
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1587 |
-
|
| 1588 |
-
|
| 1589 |
|
| 1590 |
if not name or not price_str:
|
| 1591 |
flash("Название и цена товара обязательны.", 'error')
|
|
@@ -1599,7 +1592,7 @@ def admin():
|
|
| 1599 |
return redirect(url_for('admin'))
|
| 1600 |
|
| 1601 |
photos_list = []
|
| 1602 |
-
if photos_files and
|
| 1603 |
uploads_dir = 'uploads_temp'
|
| 1604 |
os.makedirs(uploads_dir, exist_ok=True)
|
| 1605 |
api = HfApi()
|
|
@@ -1612,7 +1605,7 @@ def admin():
|
|
| 1612 |
break
|
| 1613 |
if photo and photo.filename:
|
| 1614 |
try:
|
| 1615 |
-
ext = os.path.splitext(photo.filename)[1]
|
| 1616 |
photo_filename = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
|
| 1617 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1618 |
photo.save(temp_path)
|
|
@@ -1635,7 +1628,7 @@ def admin():
|
|
| 1635 |
elif photo and not photo.filename:
|
| 1636 |
logging.warning("Получен пустой объект файла фото при добавлении товара.")
|
| 1637 |
try:
|
| 1638 |
-
|
| 1639 |
if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
|
| 1640 |
os.rmdir(uploads_dir)
|
| 1641 |
except OSError as e:
|
|
@@ -1646,29 +1639,28 @@ def admin():
|
|
| 1646 |
'name': name, 'price': price, 'description': description,
|
| 1647 |
'category': category if category in categories else 'Без категории',
|
| 1648 |
'photos': photos_list, 'colors': colors,
|
| 1649 |
-
'
|
| 1650 |
}
|
| 1651 |
-
|
| 1652 |
-
|
| 1653 |
-
|
| 1654 |
save_data(data)
|
| 1655 |
-
logging.info(f"Товар '{name}'
|
| 1656 |
flash(f"Товар '{name}' успешно добавлен.", 'success')
|
| 1657 |
|
| 1658 |
elif action == 'edit_product':
|
| 1659 |
-
index_str = request.form.get('
|
| 1660 |
if index_str is None:
|
| 1661 |
flash("Ошибка редактирования: индекс товара не передан.", 'error')
|
| 1662 |
return redirect(url_for('admin'))
|
| 1663 |
|
| 1664 |
try:
|
| 1665 |
-
# Index refers to the position in the ORIGINAL all_products list from data
|
| 1666 |
index = int(index_str)
|
| 1667 |
-
if not (0 <= index < len(
|
| 1668 |
-
product_to_edit =
|
| 1669 |
original_name = product_to_edit.get('name', 'N/A')
|
| 1670 |
-
except (ValueError, IndexError)
|
| 1671 |
-
flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.
|
| 1672 |
return redirect(url_for('admin'))
|
| 1673 |
|
| 1674 |
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
|
|
@@ -1677,8 +1669,8 @@ def admin():
|
|
| 1677 |
category = request.form.get('category')
|
| 1678 |
product_to_edit['category'] = category if category in categories else 'Без категории'
|
| 1679 |
product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1680 |
-
product_to_edit['
|
| 1681 |
-
product_to_edit['
|
| 1682 |
|
| 1683 |
try:
|
| 1684 |
price = round(float(price_str), 2)
|
|
@@ -1704,7 +1696,7 @@ def admin():
|
|
| 1704 |
break
|
| 1705 |
if photo and photo.filename:
|
| 1706 |
try:
|
| 1707 |
-
ext = os.path.splitext(photo.filename)[1]
|
| 1708 |
photo_filename = secure_filename(f"{product_to_edit['name'].replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
|
| 1709 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1710 |
photo.save(temp_path)
|
|
@@ -1731,39 +1723,52 @@ def admin():
|
|
| 1731 |
if old_photos:
|
| 1732 |
logging.info(f"Попытка удаления старых фото: {old_photos}")
|
| 1733 |
try:
|
| 1734 |
-
api
|
| 1735 |
-
|
| 1736 |
-
|
| 1737 |
-
|
| 1738 |
-
|
| 1739 |
-
|
| 1740 |
-
|
| 1741 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1742 |
except Exception as e:
|
| 1743 |
-
#
|
| 1744 |
-
|
| 1745 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1746 |
product_to_edit['photos'] = new_photos_list
|
| 1747 |
flash("Фотографии товара успешно обновлены.", "success")
|
| 1748 |
elif uploaded_count == 0 and any(f.filename for f in photos_files):
|
| 1749 |
flash("Не удалось загрузить новые фотографии.", "error")
|
| 1750 |
|
| 1751 |
-
#
|
|
|
|
| 1752 |
save_data(data)
|
| 1753 |
-
logging.info(f"Товар '{original_name}' (
|
| 1754 |
flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
|
| 1755 |
|
| 1756 |
|
| 1757 |
elif action == 'delete_product':
|
| 1758 |
-
index_str = request.form.get('
|
| 1759 |
if index_str is None:
|
| 1760 |
flash("Ошибка удаления: индекс товара не передан.", 'error')
|
| 1761 |
return redirect(url_for('admin'))
|
| 1762 |
try:
|
| 1763 |
index = int(index_str)
|
| 1764 |
-
|
| 1765 |
-
|
| 1766 |
-
deleted_product = data['products'].pop(index) # Remove from the source list
|
| 1767 |
product_name = deleted_product.get('name', 'N/A')
|
| 1768 |
|
| 1769 |
photos_to_delete = deleted_product.get('photos', [])
|
|
@@ -1771,23 +1776,31 @@ def admin():
|
|
| 1771 |
logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}")
|
| 1772 |
try:
|
| 1773 |
api = HfApi()
|
| 1774 |
-
|
| 1775 |
-
|
| 1776 |
-
|
| 1777 |
-
|
| 1778 |
-
|
| 1779 |
-
|
| 1780 |
-
|
| 1781 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1782 |
except Exception as e:
|
| 1783 |
-
|
| 1784 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1785 |
|
| 1786 |
save_data(data)
|
| 1787 |
-
logging.info(f"Товар '{product_name}' (
|
| 1788 |
flash(f"Товар '{product_name}' удален.", 'success')
|
| 1789 |
-
except (ValueError, IndexError)
|
| 1790 |
-
flash(f"Ошибка удаления: неверный индекс товара '{index_str}'.
|
| 1791 |
|
| 1792 |
|
| 1793 |
elif action == 'add_user':
|
|
@@ -1838,18 +1851,14 @@ def admin():
|
|
| 1838 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1839 |
return redirect(url_for('admin'))
|
| 1840 |
|
| 1841 |
-
#
|
| 1842 |
-
|
| 1843 |
-
all_products = data.get('products', [])
|
| 1844 |
-
all_products.sort(key=lambda x: x.get('name', '').lower()) # Sort for display
|
| 1845 |
-
categories = data.get('categories', [])
|
| 1846 |
categories.sort()
|
| 1847 |
-
users = load_users()
|
| 1848 |
sorted_users = dict(sorted(users.items()))
|
| 1849 |
|
| 1850 |
return render_template_string(
|
| 1851 |
ADMIN_TEMPLATE,
|
| 1852 |
-
|
| 1853 |
categories=categories,
|
| 1854 |
users=sorted_users,
|
| 1855 |
repo_id=REPO_ID,
|
|
@@ -1858,6 +1867,10 @@ def admin():
|
|
| 1858 |
|
| 1859 |
@app.route('/force_upload', methods=['POST'])
|
| 1860 |
def force_upload():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1861 |
logging.info("Запущена принудительная загрузка данных на Hugging Face...")
|
| 1862 |
try:
|
| 1863 |
upload_db_to_hf()
|
|
@@ -1869,10 +1882,18 @@ def force_upload():
|
|
| 1869 |
|
| 1870 |
@app.route('/force_download', methods=['POST'])
|
| 1871 |
def force_download():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1872 |
logging.info("Запущено принудительное скачивание данных с Hugging Face...")
|
| 1873 |
try:
|
| 1874 |
download_db_from_hf()
|
| 1875 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1876 |
except Exception as e:
|
| 1877 |
logging.error(f"Ошибка при принудительном скачивании: {e}", exc_info=True)
|
| 1878 |
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
|
|
@@ -1880,7 +1901,21 @@ def force_download():
|
|
| 1880 |
|
| 1881 |
|
| 1882 |
if __name__ == '__main__':
|
| 1883 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1884 |
load_data()
|
| 1885 |
load_users()
|
| 1886 |
|
|
@@ -1893,10 +1928,6 @@ if __name__ == '__main__':
|
|
| 1893 |
|
| 1894 |
port = int(os.environ.get('PORT', 7860))
|
| 1895 |
logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
|
| 1896 |
-
#
|
| 1897 |
-
# Example with Waitress (install waitress first: pip install waitress)
|
| 1898 |
-
# from waitress import serve
|
| 1899 |
-
# serve(app, host='0.0.0.0', port=port)
|
| 1900 |
-
# For development:
|
| 1901 |
app.run(debug=False, host='0.0.0.0', port=port)
|
| 1902 |
|
|
|
|
| 1 |
|
| 2 |
+
from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash, abort
|
|
|
|
| 3 |
import json
|
| 4 |
import os
|
| 5 |
import logging
|
|
|
|
| 39 |
logging.info(f"Данные успешно загружены из {DATA_FILE}")
|
| 40 |
if not isinstance(data, dict):
|
| 41 |
logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.")
|
| 42 |
+
return {'products': [], 'categories': []}
|
|
|
|
| 43 |
if 'products' not in data:
|
| 44 |
data['products'] = []
|
| 45 |
if 'categories' not in data:
|
| 46 |
data['categories'] = []
|
| 47 |
+
# Ensure default values for new fields if missing
|
|
|
|
| 48 |
for product in data['products']:
|
| 49 |
+
product.setdefault('is_featured', False)
|
| 50 |
product.setdefault('in_stock', True)
|
|
|
|
| 51 |
return data
|
| 52 |
except FileNotFoundError:
|
| 53 |
logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачать с HF.")
|
|
|
|
| 63 |
if not isinstance(data, dict): return {'products': [], 'categories': []}
|
| 64 |
if 'products' not in data: data['products'] = []
|
| 65 |
if 'categories' not in data: data['categories'] = []
|
|
|
|
| 66 |
for product in data['products']:
|
| 67 |
+
product.setdefault('is_featured', False)
|
| 68 |
product.setdefault('in_stock', True)
|
| 69 |
return data
|
| 70 |
except (FileNotFoundError, RepositoryNotFoundError) as e:
|
|
|
|
| 88 |
|
| 89 |
def save_data(data):
|
| 90 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 92 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 93 |
logging.info(f"Данные успешно сохранены в {DATA_FILE}")
|
|
|
|
| 190 |
logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.")
|
| 191 |
break
|
| 192 |
except Exception as e:
|
| 193 |
+
if "404" in str(e) or isinstance(e, FileNotFoundError):
|
| 194 |
logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.")
|
| 195 |
else:
|
| 196 |
logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True)
|
|
|
|
| 210 |
upload_db_to_hf()
|
| 211 |
logging.info("Периодическое резервное копирование завершено.")
|
| 212 |
|
| 213 |
+
@app.route('/favicon.ico')
|
| 214 |
+
def favicon():
|
| 215 |
+
return send_file('static/favicon.ico', mimetype='image/vnd.microsoft.icon', max_age=0) # Use max_age=0 for debugging
|
| 216 |
|
| 217 |
@app.route('/')
|
| 218 |
def catalog():
|
|
|
|
| 221 |
categories = data.get('categories', [])
|
| 222 |
is_authenticated = 'user' in session
|
| 223 |
|
| 224 |
+
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 225 |
+
products_in_stock.sort(key=lambda p: (not p.get('is_featured', False), p.get('name', '').lower()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
catalog_html = '''
|
| 228 |
<!DOCTYPE html>
|
|
|
|
| 231 |
<meta charset="UTF-8">
|
| 232 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 233 |
<title>Soola Cosmetics - Каталог</title>
|
| 234 |
+
<link rel="icon" href="/favicon.ico">
|
| 235 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 236 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 237 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
|
|
|
|
| 272 |
body.dark-mode .product { background: #253f37; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #2c4a41; }
|
| 273 |
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
|
| 274 |
body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
|
| 275 |
+
.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; }
|
| 276 |
.product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; }
|
| 277 |
.product-image img:hover { transform: scale(1.08); }
|
|
|
|
| 278 |
.product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
|
| 279 |
.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: #2d332f; }
|
| 280 |
body.dark-mode .product h2 { color: #c8d8d3; }
|
|
|
|
| 286 |
.product-button { display: block; width: 100%; padding: 10px; border: none; border-radius: 8px; background-color: #1C6758; 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; }
|
| 287 |
.product-button:hover { background-color: #164B41; box-shadow: 0 4px 15px rgba(22, 75, 65, 0.4); transform: translateY(-2px); }
|
| 288 |
.product-button i { margin-right: 5px; }
|
| 289 |
+
.product.featured { border-left: 5px solid #ffc107; } /* Style for featured */
|
| 290 |
|
| 291 |
.add-to-cart { background-color: #38a169; }
|
| 292 |
.add-to-cart:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
|
|
|
| 345 |
<a href="{{ url_for('login') }}">Войти</a>
|
| 346 |
{% endif %}
|
| 347 |
</div>
|
| 348 |
+
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Переключить тему">
|
| 349 |
<i class="fas fa-moon"></i>
|
| 350 |
</button>
|
| 351 |
</div>
|
|
|
|
| 365 |
|
| 366 |
<div class="products-grid" id="products-grid">
|
| 367 |
{% for product in products %}
|
| 368 |
+
<div class="product {% if product.get('is_featured') %}featured{% endif %}"
|
| 369 |
data-name="{{ product['name']|lower }}"
|
| 370 |
data-description="{{ product.get('description', '')|lower }}"
|
| 371 |
+
data-category="{{ product.get('category', 'Без категории') }}"
|
| 372 |
+
data-id="{{ loop.index0 }}"> {# Use original index for mapping #}
|
| 373 |
<div class="product-image">
|
|
|
|
|
|
|
|
|
|
| 374 |
{% if product.get('photos') and product['photos']|length > 0 %}
|
| 375 |
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
|
| 376 |
alt="{{ product['name'] }}"
|
|
|
|
| 380 |
{% endif %}
|
| 381 |
</div>
|
| 382 |
<div class="product-info">
|
| 383 |
+
<h2>{{ product['name'] }} {% if product.get('is_featured') %}<i class="fas fa-star" style="color: #ffc107; font-size: 0.8em;" title="Популярный товар"></i>{% endif %}</h2>
|
| 384 |
{% if is_authenticated %}
|
| 385 |
<div class="product-price">{{ "%.2f"|format(product['price']) }} {{ currency_code }}</div>
|
| 386 |
{% else %}
|
|
|
|
| 389 |
<p class="product-description">{{ product.get('description', '')[:50] }}{% if product.get('description', '')|length > 50 %}...{% endif %}</p>
|
| 390 |
</div>
|
| 391 |
<div class="product-actions">
|
| 392 |
+
<button class="product-button" onclick="openModal({{ loop.index0 }})">Подробнее</button>
|
| 393 |
{% if is_authenticated %}
|
| 394 |
+
<button class="product-button add-to-cart" onclick="openQuantityModal({{ loop.index0 }})">
|
| 395 |
<i class="fas fa-cart-plus"></i> В корзину
|
| 396 |
</button>
|
| 397 |
{% endif %}
|
|
|
|
| 399 |
</div>
|
| 400 |
{% endfor %}
|
| 401 |
{% if not products %}
|
| 402 |
+
<p class="no-results-message">Товары пока не добавлены или не соответствуют фильтру.</p>
|
| 403 |
{% endif %}
|
| 404 |
+
<p id="no-results-placeholder" class="no-results-message" style="display: none;">По вашему запросу товары не найдены.</p>
|
| 405 |
</div>
|
| 406 |
</div>
|
| 407 |
|
|
|
|
| 452 |
|
| 453 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
|
| 454 |
<script>
|
| 455 |
+
// Make sure product data is available globally AFTER DOM is loaded or handled correctly
|
| 456 |
+
let products = [];
|
| 457 |
const repoId = '{{ repo_id }}';
|
| 458 |
const currencyCode = '{{ currency_code }}';
|
| 459 |
const isAuthenticated = {{ is_authenticated|tojson }};
|
| 460 |
let selectedProductIndex = null;
|
| 461 |
let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
|
| 462 |
|
| 463 |
+
// --- Function Definitions ---
|
| 464 |
function toggleTheme() {
|
| 465 |
document.body.classList.toggle('dark-mode');
|
| 466 |
const icon = document.querySelector('.theme-toggle i');
|
|
|
|
| 476 |
document.body.classList.add('dark-mode');
|
| 477 |
const icon = document.querySelector('.theme-toggle i');
|
| 478 |
if (icon) icon.classList.replace('fa-moon', 'fa-sun');
|
| 479 |
+
} else {
|
| 480 |
+
const icon = document.querySelector('.theme-toggle i');
|
| 481 |
+
if (icon) icon.classList.replace('fa-sun', 'fa-moon');
|
| 482 |
}
|
| 483 |
}
|
| 484 |
|
| 485 |
+
function attemptAutoLogin() {
|
| 486 |
const storedUser = localStorage.getItem('soolaUser');
|
| 487 |
if (storedUser && !isAuthenticated) {
|
| 488 |
console.log('Attempting auto-login for:', storedUser);
|
|
|
|
| 497 |
window.location.reload();
|
| 498 |
} else {
|
| 499 |
response.text().then(text => console.log(`Auto-login failed: ${response.status} ${text}`));
|
| 500 |
+
localStorage.removeItem('soolaUser'); // Remove invalid stored user
|
| 501 |
}
|
| 502 |
})
|
| 503 |
.catch(error => {
|
| 504 |
console.error('Auto-login fetch error:', error);
|
| 505 |
+
localStorage.removeItem('soolaUser'); // Remove on error too
|
| 506 |
});
|
| 507 |
}
|
| 508 |
}
|
| 509 |
|
| 510 |
+
function openModal(productIndex) {
|
| 511 |
+
// Fetch details using the original index
|
| 512 |
+
loadProductDetails(productIndex);
|
| 513 |
const modal = document.getElementById('productModal');
|
| 514 |
if (modal) {
|
| 515 |
modal.style.display = "block";
|
|
|
|
| 522 |
if (modal) {
|
| 523 |
modal.style.display = "none";
|
| 524 |
}
|
| 525 |
+
// Check if ANY modal is still open before re-enabling scroll
|
| 526 |
const anyModalOpen = document.querySelector('.modal[style*="display: block"]');
|
| 527 |
if (!anyModalOpen) {
|
| 528 |
document.body.style.overflow = 'auto';
|
|
|
|
| 533 |
const modalContent = document.getElementById('modalContent');
|
| 534 |
if (!modalContent) return;
|
| 535 |
modalContent.innerHTML = '<p style="text-align:center; padding: 40px;">Загрузка...</p>';
|
| 536 |
+
// Use the original index passed from the backend
|
| 537 |
fetch('/product/' + index)
|
| 538 |
.then(response => {
|
| 539 |
if (!response.ok) throw new Error(`Ошибка ${response.status}: ${response.statusText}`);
|
|
|
|
| 555 |
new Swiper(swiperContainer, {
|
| 556 |
slidesPerView: 1,
|
| 557 |
spaceBetween: 20,
|
| 558 |
+
loop: true, // Looping might cause issues if only 1 image
|
| 559 |
grabCursor: true,
|
| 560 |
pagination: { el: '.swiper-pagination', clickable: true },
|
| 561 |
navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
|
|
|
|
| 565 |
}
|
| 566 |
}
|
| 567 |
|
| 568 |
+
function openQuantityModal(productIndex) {
|
| 569 |
if (!isAuthenticated) {
|
| 570 |
alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
|
| 571 |
window.location.href = '/login';
|
| 572 |
return;
|
| 573 |
}
|
| 574 |
+
selectedProductIndex = productIndex; // Store the original index
|
| 575 |
+
const product = products[selectedProductIndex]; // Access using original index
|
| 576 |
+
|
| 577 |
if (!product) {
|
| 578 |
+
console.error("Product not found for index:", productIndex);
|
| 579 |
alert("Ошибка: товар не найден.");
|
| 580 |
return;
|
| 581 |
}
|
|
|
|
| 596 |
colorSelect.style.display = 'block';
|
| 597 |
if(colorLabel) colorLabel.style.display = 'block';
|
| 598 |
} else {
|
| 599 |
+
// Add a default "N/A" option if no colors, but hide it
|
| 600 |
+
const option = document.createElement('option');
|
| 601 |
+
option.value = 'N/A';
|
| 602 |
+
option.text = 'N/A';
|
| 603 |
+
colorSelect.appendChild(option);
|
| 604 |
colorSelect.style.display = 'none';
|
| 605 |
if(colorLabel) colorLabel.style.display = 'none';
|
| 606 |
}
|
|
|
|
| 613 |
}
|
| 614 |
}
|
| 615 |
|
| 616 |
+
|
| 617 |
function confirmAddToCart() {
|
| 618 |
if (selectedProductIndex === null) return;
|
| 619 |
|
| 620 |
const quantityInput = document.getElementById('quantityInput');
|
| 621 |
const quantity = parseInt(quantityInput.value);
|
| 622 |
const colorSelect = document.getElementById('colorSelect');
|
| 623 |
+
// Use selected value if visible, otherwise 'N/A'
|
| 624 |
const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A';
|
| 625 |
|
| 626 |
if (isNaN(quantity) || quantity <= 0) {
|
|
|
|
| 629 |
return;
|
| 630 |
}
|
| 631 |
|
| 632 |
+
const product = products[selectedProductIndex]; // Access using original index
|
| 633 |
if (!product) {
|
| 634 |
alert("Ошибка добавления: товар не найден.");
|
| 635 |
return;
|
| 636 |
}
|
| 637 |
|
| 638 |
+
// Use product index and color to make ID unique even if names are same
|
| 639 |
+
const cartItemId = `product-${selectedProductIndex}-${color}`;
|
| 640 |
const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
|
| 641 |
|
| 642 |
if (existingItemIndex > -1) {
|
| 643 |
cart[existingItemIndex].quantity += quantity;
|
| 644 |
} else {
|
| 645 |
cart.push({
|
| 646 |
+
id: cartItemId, // Use unique ID
|
| 647 |
name: product.name,
|
| 648 |
price: product.price,
|
| 649 |
photo: product.photos && product.photos.length > 0 ? product.photos[0] : null,
|
|
|
|
| 656 |
closeModal('quantityModal');
|
| 657 |
updateCartButton();
|
| 658 |
showNotification(`${product.name} добавлен в корзину!`);
|
| 659 |
+
selectedProductIndex = null; // Reset selected index
|
| 660 |
}
|
| 661 |
|
| 662 |
+
|
| 663 |
function updateCartButton() {
|
| 664 |
const cartCountElement = document.getElementById('cart-count');
|
| 665 |
const cartButton = document.getElementById('cart-button');
|
|
|
|
| 668 |
let totalItems = 0;
|
| 669 |
cart.forEach(item => { totalItems += item.quantity; });
|
| 670 |
|
| 671 |
+
if (totalItems > 0 && isAuthenticated) { // Show only if logged in and items exist
|
| 672 |
cartCountElement.textContent = totalItems;
|
| 673 |
cartButton.style.display = 'flex';
|
| 674 |
} else {
|
|
|
|
| 678 |
}
|
| 679 |
|
| 680 |
function openCartModal() {
|
| 681 |
+
if (!isAuthenticated) { // Should not happen if button isn't shown, but double-check
|
| 682 |
+
return;
|
| 683 |
+
}
|
| 684 |
const cartContent = document.getElementById('cartContent');
|
| 685 |
const cartTotalElement = document.getElementById('cartTotal');
|
| 686 |
if (!cartContent || !cartTotalElement) return;
|
|
|
|
| 723 |
function removeFromCart(itemId) {
|
| 724 |
cart = cart.filter(item => item.id !== itemId);
|
| 725 |
localStorage.setItem('soolaCart', JSON.stringify(cart));
|
| 726 |
+
openCartModal(); // Refresh cart view
|
| 727 |
+
updateCartButton(); // Update bubble count
|
| 728 |
}
|
| 729 |
|
| 730 |
function clearCart() {
|
| 731 |
if (confirm("Вы уверены, что хотите очистить корзину?")) {
|
| 732 |
cart = [];
|
| 733 |
localStorage.removeItem('soolaCart');
|
| 734 |
+
openCartModal(); // Refresh cart view
|
| 735 |
+
updateCartButton(); // Update bubble count
|
| 736 |
}
|
| 737 |
}
|
| 738 |
|
|
|
|
| 757 |
orderText += `*Итого: ${total.toFixed(2)} ${currencyCode}*\n\n`;
|
| 758 |
orderText += "--- Заказчик ---\n";
|
| 759 |
|
| 760 |
+
// Fetch current user info directly if possible, or use session data if available client-side
|
| 761 |
+
// Assuming session data is passed correctly and available via `isAuthenticated` check
|
| 762 |
+
// The Python template passes session data, but it's safer to get it fresh if needed, though not standard for client-side JS
|
| 763 |
+
// We rely on the `session['user_info']` passed during render
|
| 764 |
+
const userInfo = {{ session.get('user_info', {})|tojson }}; // Use data injected by Jinja
|
| 765 |
if (userInfo && userInfo.login) {
|
| 766 |
orderText += `Имя: ${userInfo.first_name || ''} ${userInfo.last_name || ''}\n`;
|
| 767 |
orderText += `Логин: ${userInfo.login}\n`;
|
|
|
|
| 776 |
const dateTimeString = now.toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'medium'});
|
| 777 |
orderText += `\nДата заказа: ${dateTimeString}`;
|
| 778 |
|
| 779 |
+
const whatsappNumber = "996997703090"; // Replace if needed
|
| 780 |
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
|
| 781 |
window.open(whatsappUrl, '_blank');
|
| 782 |
}
|
| 783 |
|
| 784 |
+
function filterProducts() {
|
| 785 |
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
|
| 786 |
const activeCategoryButton = document.querySelector('.category-filter.active');
|
| 787 |
const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all';
|
| 788 |
const grid = document.getElementById('products-grid');
|
| 789 |
+
const noResultsPlaceholder = document.getElementById('no-results-placeholder');
|
| 790 |
let visibleProducts = 0;
|
| 791 |
|
| 792 |
+
grid.querySelectorAll('.product').forEach(productElement => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
const name = productElement.getAttribute('data-name');
|
| 794 |
const description = productElement.getAttribute('data-description');
|
| 795 |
const category = productElement.getAttribute('data-category');
|
|
|
|
| 798 |
const matchesCategory = activeCategory === 'all' || category === activeCategory;
|
| 799 |
|
| 800 |
if (matchesSearch && matchesCategory) {
|
| 801 |
+
productElement.style.display = 'flex'; // Use flex as defined in CSS
|
| 802 |
visibleProducts++;
|
| 803 |
} else {
|
| 804 |
productElement.style.display = 'none';
|
| 805 |
}
|
| 806 |
});
|
| 807 |
|
| 808 |
+
// Show/hide the "no results" message specifically for filtering
|
| 809 |
if (visibleProducts === 0 && (searchTerm || activeCategory !== 'all')) {
|
| 810 |
+
if (noResultsPlaceholder) noResultsPlaceholder.style.display = 'block';
|
| 811 |
+
} else {
|
| 812 |
+
if (noResultsPlaceholder) noResultsPlaceholder.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
}
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
|
| 817 |
function setupFilters() {
|
| 818 |
const searchInput = document.getElementById('search-input');
|
| 819 |
const categoryFilters = document.querySelectorAll('.category-filter');
|
| 820 |
|
| 821 |
+
if(searchInput) {
|
| 822 |
+
searchInput.addEventListener('input', filterProducts);
|
| 823 |
+
}
|
| 824 |
|
| 825 |
categoryFilters.forEach(filter => {
|
| 826 |
filter.addEventListener('click', function() {
|
|
|
|
| 833 |
|
| 834 |
function showNotification(message, duration = 3000) {
|
| 835 |
const placeholder = document.getElementById('notification-placeholder');
|
| 836 |
+
if (!placeholder) {
|
| 837 |
+
// Create placeholder if it doesn't exist
|
| 838 |
+
const newPlaceholder = document.createElement('div');
|
| 839 |
+
newPlaceholder.id = 'notification-placeholder';
|
| 840 |
+
document.body.appendChild(newPlaceholder);
|
| 841 |
+
placeholder = newPlaceholder;
|
| 842 |
+
}
|
| 843 |
|
| 844 |
const notification = document.createElement('div');
|
| 845 |
notification.className = 'notification';
|
| 846 |
notification.textContent = message;
|
| 847 |
placeholder.appendChild(notification);
|
| 848 |
|
| 849 |
+
// Trigger reflow to enable transition
|
| 850 |
+
void notification.offsetWidth;
|
| 851 |
+
|
| 852 |
+
// Add 'show' class to fade in
|
| 853 |
+
notification.classList.add('show');
|
| 854 |
|
| 855 |
+
// Set timer to remove notification
|
| 856 |
setTimeout(() => {
|
| 857 |
notification.classList.remove('show');
|
| 858 |
+
// Remove element after fade out transition ends
|
| 859 |
+
notification.addEventListener('transitionend', () => notification.remove());
|
| 860 |
}, duration);
|
| 861 |
}
|
| 862 |
|
| 863 |
+
// --- Initialization ---
|
| 864 |
document.addEventListener('DOMContentLoaded', () => {
|
| 865 |
+
// Load product data into the global variable AFTER DOM is ready
|
| 866 |
+
// The data is injected directly into the script tag by Jinja
|
| 867 |
+
products = {{ products|tojson }};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
|
| 869 |
+
applyInitialTheme();
|
| 870 |
+
attemptAutoLogin(); // Attempt auto-login if needed
|
| 871 |
+
updateCartButton(); // Initial cart button state
|
| 872 |
+
setupFilters(); // Set up search and category filters
|
| 873 |
|
| 874 |
+
// Close modal on background click
|
| 875 |
window.addEventListener('click', function(event) {
|
| 876 |
if (event.target.classList.contains('modal')) {
|
| 877 |
closeModal(event.target.id);
|
| 878 |
}
|
| 879 |
});
|
| 880 |
|
| 881 |
+
// Close modal on Escape key
|
| 882 |
window.addEventListener('keydown', function(event) {
|
| 883 |
if (event.key === 'Escape') {
|
| 884 |
document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
|
|
|
|
| 886 |
});
|
| 887 |
}
|
| 888 |
});
|
| 889 |
+
|
| 890 |
+
// Initial filter application in case the page loads with filters active (e.g., back button)
|
| 891 |
+
filterProducts();
|
| 892 |
});
|
| 893 |
|
| 894 |
</script>
|
|
|
|
| 897 |
'''
|
| 898 |
return render_template_string(
|
| 899 |
catalog_html,
|
| 900 |
+
products=products_in_stock, # Pass filtered & sorted list
|
| 901 |
categories=categories,
|
| 902 |
repo_id=REPO_ID,
|
| 903 |
is_authenticated=is_authenticated,
|
|
|
|
| 910 |
@app.route('/product/<int:index>')
|
| 911 |
def product_detail(index):
|
| 912 |
data = load_data()
|
| 913 |
+
products = data.get('products', [])
|
| 914 |
is_authenticated = 'user' in session
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
try:
|
| 916 |
+
# Use the index directly as passed from the catalog
|
| 917 |
+
product = products[index]
|
| 918 |
+
if not product.get('in_stock', True):
|
| 919 |
+
# Optionally, decide if out-of-stock items can be viewed directly
|
| 920 |
+
# For now, let's allow viewing but maybe add a note later
|
| 921 |
+
pass
|
| 922 |
except IndexError:
|
| 923 |
+
logging.warning(f"Попытка доступа к несуществующему продукту с индексом {index}")
|
| 924 |
+
abort(404, "Товар не найден") # Use abort for standard 404 page
|
| 925 |
|
| 926 |
detail_html = '''
|
| 927 |
<div style="padding: 10px;">
|
| 928 |
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #1C6758;">{{ product['name'] }}</h2>
|
| 929 |
{% if not product.get('in_stock', True) %}
|
| 930 |
+
<p style="text-align: center; color: red; font-weight: bold; margin-bottom: 15px;">Нет в наличии</p>
|
| 931 |
{% endif %}
|
|
|
|
|
|
|
|
|
|
| 932 |
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
|
| 933 |
<div class="swiper-wrapper">
|
| 934 |
{% if product.get('photos') and product['photos']|length > 0 %}
|
|
|
|
| 967 |
<p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p>
|
| 968 |
{% endif %}
|
| 969 |
</div>
|
| 970 |
+
{% if is_authenticated and product.get('in_stock', True) %}
|
| 971 |
+
<div style="text-align: center; margin-top: 20px;">
|
| 972 |
+
<button class="product-button add-to-cart" onclick="closeModal('productModal'); openQuantityModal({{ index }});">
|
| 973 |
+
<i class="fas fa-cart-plus"></i> В корзину
|
| 974 |
+
</button>
|
| 975 |
+
</div>
|
| 976 |
+
{% endif %}
|
| 977 |
</div>
|
| 978 |
'''
|
| 979 |
+
# Pass the original index back to the template for the add-to-cart button
|
| 980 |
return render_template_string(
|
| 981 |
detail_html,
|
| 982 |
product=product,
|
| 983 |
+
index=index,
|
| 984 |
repo_id=REPO_ID,
|
| 985 |
is_authenticated=is_authenticated,
|
| 986 |
currency_code=CURRENCY_CODE
|
|
|
|
| 993 |
<meta charset="UTF-8">
|
| 994 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 995 |
<title>Вход - Soola Cosmetics</title>
|
| 996 |
+
<link rel="icon" href="/favicon.ico">
|
| 997 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 998 |
<style>
|
| 999 |
body { font-family: 'Poppins', sans-serif; background: linear-gradient(135deg, #d1e7dd, #e9f5f0); display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
|
|
|
|
| 1051 |
}
|
| 1052 |
logging.info(f"Пользователь {login} успешно вошел в систему.")
|
| 1053 |
login_response_html = f'''
|
| 1054 |
+
<!DOCTYPE html><html><head><title>Перенаправление...</title><link rel="icon" href="/favicon.ico"></head><body>
|
| 1055 |
<script>
|
| 1056 |
try {{ localStorage.setItem('soolaUser', '{login}'); }} catch (e) {{ console.error("Ошибка сохранения в localStorage:", e); }}
|
| 1057 |
window.location.href = "{url_for('catalog')}";
|
|
|
|
| 1096 |
return "OK", 200
|
| 1097 |
else:
|
| 1098 |
logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.")
|
| 1099 |
+
# Clear invalid user from localStorage on client side via response header or message
|
| 1100 |
+
return "Ошибка авто-входа", 400 # 400 or 401 might be appropriate
|
| 1101 |
|
| 1102 |
@app.route('/logout')
|
| 1103 |
def logout():
|
|
|
|
| 1107 |
if logged_out_user:
|
| 1108 |
logging.info(f"Пользователь {logged_out_user} вышел из системы.")
|
| 1109 |
logout_response_html = '''
|
| 1110 |
+
<!DOCTYPE html><html><head><title>Выход...</title><link rel="icon" href="/favicon.ico"></head><body>
|
| 1111 |
<script>
|
| 1112 |
try { localStorage.removeItem('soolaUser'); } catch (e) { console.error("Ошибка удаления из localStorage:", e); }
|
| 1113 |
window.location.href = "/";
|
|
|
|
| 1124 |
<meta charset="UTF-8">
|
| 1125 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 1126 |
<title>Админ-панель - Soola Cosmetics</title>
|
| 1127 |
+
<link rel="icon" href="/favicon.ico">
|
| 1128 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 1129 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 1130 |
<style>
|
| 1131 |
body { font-family: 'Poppins', sans-serif; background-color: #e9f5f0; color: #2d332f; padding: 20px; line-height: 1.6; }
|
| 1132 |
.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); }
|
| 1133 |
.header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #d1e7dd; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
|
| 1134 |
+
h1, h2, h3, h4 { font-weight: 600; color: #1C6758; margin-bottom: 15px; }
|
| 1135 |
h1 { font-size: 1.8rem; }
|
| 1136 |
h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
|
| 1137 |
h3 { font-size: 1.2rem; color: #164B41; margin-top: 20px; }
|
| 1138 |
+
h4 { font-size: 1.1rem; margin-bottom: 10px; color: #1C6758;}
|
| 1139 |
.section { margin-bottom: 30px; padding: 20px; background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; }
|
| 1140 |
form { margin-bottom: 20px; }
|
| 1141 |
label { font-weight: 500; margin-top: 10px; display: block; color: #44524c; font-size: 0.9rem;}
|
|
|
|
| 1144 |
textarea { min-height: 80px; resize: vertical; }
|
| 1145 |
input[type="file"] { padding: 8px; background-color: #f0f9f4; cursor: pointer; border: 1px solid #c4d9d1;}
|
| 1146 |
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #e0f0e9; border: 1px solid #c4d9d1; cursor: pointer; margin-right: 10px;}
|
|
|
|
|
|
|
| 1147 |
button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #1C6758; 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;}
|
| 1148 |
button:hover, .button:hover { background-color: #164B41; }
|
| 1149 |
button:active, .button:active { transform: scale(0.98); }
|
|
|
|
| 1156 |
.item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #e1f0e9; }
|
| 1157 |
.item p { margin: 5px 0; font-size: 0.9rem; color: #44524c; }
|
| 1158 |
.item strong { color: #2d332f; }
|
| 1159 |
+
.item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; display: block; } /* Ensure block for ellipsis */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1160 |
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
|
| 1161 |
.item-actions button:not(.delete-button) { background-color: #1C6758; }
|
| 1162 |
.item-actions button:not(.delete-button):hover { background-color: #164B41; }
|
|
|
|
| 1183 |
.message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
|
| 1184 |
.message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
|
| 1185 |
.message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
|
| 1186 |
+
.checkbox-label { display: flex; align-items: center; gap: 5px; margin-top: 10px; }
|
| 1187 |
+
.checkbox-label input[type="checkbox"] { width: auto; margin-top: 0; }
|
| 1188 |
+
.status-indicator { font-weight: bold; font-size: 0.85rem; margin-left: 10px; padding: 3px 8px; border-radius: 4px; }
|
| 1189 |
+
.status-featured { color: #856404; background-color: #fff3cd; }
|
| 1190 |
+
.status-outofstock { color: #721c24; background-color: #f8d7da; }
|
| 1191 |
</style>
|
| 1192 |
</head>
|
| 1193 |
<body>
|
|
|
|
| 1332 |
</select>
|
| 1333 |
<label for="add_photos">Фотографии (до 10 шт.):</label>
|
| 1334 |
<input type="file" id="add_photos" name="photos" accept="image/*" multiple>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1335 |
<label>Цвета/Варианты (оставьте пустым, если нет):</label>
|
| 1336 |
<div id="add-color-inputs">
|
| 1337 |
<div class="color-input-group">
|
|
|
|
| 1340 |
</div>
|
| 1341 |
</div>
|
| 1342 |
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button>
|
| 1343 |
+
|
| 1344 |
+
<label class="checkbox-label">
|
| 1345 |
+
<input type="checkbox" id="add_in_stock" name="in_stock" value="true" checked>
|
| 1346 |
+
<span>В наличии? (Снимите галочку, чтобы скрыть товар из каталога)</span>
|
| 1347 |
+
</label>
|
| 1348 |
+
<label class="checkbox-label">
|
| 1349 |
+
<input type="checkbox" id="add_is_featured" name="is_featured" value="true">
|
| 1350 |
+
<span><i class="fas fa-star"></i> Показывать в топе? (Отмеченные товары будут первыми в списке)</span>
|
| 1351 |
+
</label>
|
| 1352 |
+
|
| 1353 |
<br>
|
| 1354 |
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button>
|
| 1355 |
</form>
|
|
|
|
| 1357 |
</details>
|
| 1358 |
|
| 1359 |
<h3>Список товаров:</h3>
|
| 1360 |
+
{% if products %}
|
| 1361 |
<div class="item-list">
|
| 1362 |
+
{% for product in products %}
|
| 1363 |
<div class="item">
|
| 1364 |
<div style="display: flex; gap: 15px; align-items: flex-start;">
|
| 1365 |
<div class="photo-preview" style="flex-shrink: 0;">
|
|
|
|
| 1372 |
{% endif %}
|
| 1373 |
</div>
|
| 1374 |
<div style="flex-grow: 1;">
|
| 1375 |
+
<h3 style="margin-top: 0; margin-bottom: 5px; color: #2d332f;">
|
| 1376 |
+
{{ product['name'] }}
|
| 1377 |
+
{% if product.get('is_featured') %}
|
| 1378 |
+
<span class="status-indicator status-featured"><i class="fas fa-star"></i> В топе</span>
|
| 1379 |
+
{% endif %}
|
| 1380 |
+
{% if not product.get('in_stock', True) %}
|
| 1381 |
+
<span class="status-indicator status-outofstock"><i class="fas fa-times-circle"></i> Нет в наличии</span>
|
| 1382 |
+
{% endif %}
|
| 1383 |
+
</h3>
|
| 1384 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 1385 |
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 1386 |
<p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
|
| 1387 |
{% set colors = product.get('colors', []) %}
|
| 1388 |
<p><strong>Цвета/Вар-ты:</strong> {{ colors|select('ne', '')|join(', ') if colors|select('ne', '')|list|length > 0 else 'Нет' }}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1389 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 1390 |
+
<p style="font-size: 0.8rem; color: #5e6e68;">(Всего фото: {{ product['photos']|length }})</p>
|
| 1391 |
{% endif %}
|
| 1392 |
</div>
|
| 1393 |
</div>
|
|
|
|
| 1396 |
<button type="button" class="button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
|
| 1397 |
<form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
|
| 1398 |
<input type="hidden" name="action" value="delete_product">
|
| 1399 |
+
<input type="hidden" name="index" value="{{ loop.index0 }}">
|
| 1400 |
<button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
|
| 1401 |
</form>
|
| 1402 |
</div>
|
|
|
|
| 1405 |
<h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
|
| 1406 |
<form method="POST" enctype="multipart/form-data">
|
| 1407 |
<input type="hidden" name="action" value="edit_product">
|
| 1408 |
+
<input type="hidden" name="index" value="{{ loop.index0 }}">
|
| 1409 |
<label>Название *:</label>
|
| 1410 |
<input type="text" name="name" value="{{ product['name'] }}" required>
|
| 1411 |
<label>Цена ({{ currency_code }}) *:</label>
|
|
|
|
| 1429 |
{% endfor %}
|
| 1430 |
</div>
|
| 1431 |
{% endif %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1432 |
<label>Цвета/Варианты:</label>
|
| 1433 |
<div id="edit-color-inputs-{{ loop.index0 }}">
|
| 1434 |
{% set current_colors = product.get('colors', []) %}
|
|
|
|
| 1449 |
{% endif %}
|
| 1450 |
</div>
|
| 1451 |
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('edit-color-inputs-{{ loop.index0 }}')"><i class="fas fa-palette"></i> Добавить поле для цвета</button>
|
| 1452 |
+
|
| 1453 |
+
<label class="checkbox-label">
|
| 1454 |
+
<input type="checkbox" name="in_stock" value="true" {% if product.get('in_stock', True) %}checked{% endif %}>
|
| 1455 |
+
<span>В наличии?</span>
|
| 1456 |
+
</label>
|
| 1457 |
+
<label class="checkbox-label">
|
| 1458 |
+
<input type="checkbox" name="is_featured" value="true" {% if product.get('is_featured', False) %}checked{% endif %}>
|
| 1459 |
+
<span><i class="fas fa-star"></i> Показывать в топе?</span>
|
| 1460 |
+
</label>
|
| 1461 |
+
|
| 1462 |
<br>
|
| 1463 |
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button>
|
| 1464 |
</form>
|
|
|
|
| 1501 |
function removeColorInput(button) {
|
| 1502 |
const group = button.closest('.color-input-group');
|
| 1503 |
if (group) {
|
| 1504 |
+
// Only remove if there's more than one input or if it's empty
|
| 1505 |
+
const container = group.parentNode;
|
| 1506 |
+
const inputs = container.querySelectorAll('.color-input-group');
|
| 1507 |
+
// Keep at least one input field available unless explicitly cleared later
|
| 1508 |
+
//if (inputs.length > 1) {
|
| 1509 |
+
group.remove();
|
| 1510 |
+
//} else {
|
| 1511 |
+
// const inputField = group.querySelector('input[name="colors"]');
|
| 1512 |
+
// if (inputField) inputField.value = ''; // Clear the last one instead of removing
|
| 1513 |
+
//}
|
| 1514 |
} else {
|
| 1515 |
console.warn("Не удалось найти родительский .color-input-group для кнопки удаления");
|
| 1516 |
}
|
|
|
|
| 1522 |
|
| 1523 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1524 |
def admin():
|
| 1525 |
+
# Authentication Check - Basic example, replace with proper roles if needed
|
| 1526 |
+
if 'user' not in session:
|
| 1527 |
+
flash("Доступ запрещен. Пожалуйста, войдите.", "error")
|
| 1528 |
+
return redirect(url_for('login'))
|
| 1529 |
+
# Add role check here if implementing roles later
|
| 1530 |
+
|
| 1531 |
data = load_data()
|
| 1532 |
+
products = data.get('products', [])
|
|
|
|
| 1533 |
categories = data.get('categories', [])
|
| 1534 |
users = load_users()
|
| 1535 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1536 |
if request.method == 'POST':
|
| 1537 |
action = request.form.get('action')
|
| 1538 |
logging.info(f"Admin action received: {action}")
|
|
|
|
| 1558 |
if category_to_delete and category_to_delete in categories:
|
| 1559 |
categories.remove(category_to_delete)
|
| 1560 |
updated_count = 0
|
| 1561 |
+
for product in products:
|
|
|
|
| 1562 |
if product.get('category') == category_to_delete:
|
| 1563 |
product['category'] = 'Без категории'
|
| 1564 |
updated_count += 1
|
|
|
|
| 1577 |
category = request.form.get('category')
|
| 1578 |
photos_files = request.files.getlist('photos')
|
| 1579 |
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1580 |
+
in_stock = request.form.get('in_stock') == 'true'
|
| 1581 |
+
is_featured = request.form.get('is_featured') == 'true'
|
| 1582 |
|
| 1583 |
if not name or not price_str:
|
| 1584 |
flash("Название и цена товара обязательны.", 'error')
|
|
|
|
| 1592 |
return redirect(url_for('admin'))
|
| 1593 |
|
| 1594 |
photos_list = []
|
| 1595 |
+
if photos_files and HF_TOKEN_WRITE:
|
| 1596 |
uploads_dir = 'uploads_temp'
|
| 1597 |
os.makedirs(uploads_dir, exist_ok=True)
|
| 1598 |
api = HfApi()
|
|
|
|
| 1605 |
break
|
| 1606 |
if photo and photo.filename:
|
| 1607 |
try:
|
| 1608 |
+
ext = os.path.splitext(photo.filename)[1]
|
| 1609 |
photo_filename = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
|
| 1610 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1611 |
photo.save(temp_path)
|
|
|
|
| 1628 |
elif photo and not photo.filename:
|
| 1629 |
logging.warning("Получен пустой объект файла фото при добавлении товара.")
|
| 1630 |
try:
|
| 1631 |
+
# Cleanup only if directory exists and is empty
|
| 1632 |
if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
|
| 1633 |
os.rmdir(uploads_dir)
|
| 1634 |
except OSError as e:
|
|
|
|
| 1639 |
'name': name, 'price': price, 'description': description,
|
| 1640 |
'category': category if category in categories else 'Без категории',
|
| 1641 |
'photos': photos_list, 'colors': colors,
|
| 1642 |
+
'in_stock': in_stock, 'is_featured': is_featured
|
| 1643 |
}
|
| 1644 |
+
products.append(new_product)
|
| 1645 |
+
# products.sort(key=lambda x: x.get('name', '').lower()) # Keep insertion order or sort later? Let's sort.
|
| 1646 |
+
products.sort(key=lambda p: (not p.get('is_featured', False), p.get('name', '').lower()))
|
| 1647 |
save_data(data)
|
| 1648 |
+
logging.info(f"Товар '{name}' добавлен (In Stock: {in_stock}, Featured: {is_featured}).")
|
| 1649 |
flash(f"Товар '{name}' успешно добавлен.", 'success')
|
| 1650 |
|
| 1651 |
elif action == 'edit_product':
|
| 1652 |
+
index_str = request.form.get('index')
|
| 1653 |
if index_str is None:
|
| 1654 |
flash("Ошибка редактирования: индекс товара не передан.", 'error')
|
| 1655 |
return redirect(url_for('admin'))
|
| 1656 |
|
| 1657 |
try:
|
|
|
|
| 1658 |
index = int(index_str)
|
| 1659 |
+
if not (0 <= index < len(products)): raise IndexError("Индекс вне диапазона")
|
| 1660 |
+
product_to_edit = products[index]
|
| 1661 |
original_name = product_to_edit.get('name', 'N/A')
|
| 1662 |
+
except (ValueError, IndexError):
|
| 1663 |
+
flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
|
| 1664 |
return redirect(url_for('admin'))
|
| 1665 |
|
| 1666 |
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
|
|
|
|
| 1669 |
category = request.form.get('category')
|
| 1670 |
product_to_edit['category'] = category if category in categories else 'Без категории'
|
| 1671 |
product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1672 |
+
product_to_edit['in_stock'] = request.form.get('in_stock') == 'true'
|
| 1673 |
+
product_to_edit['is_featured'] = request.form.get('is_featured') == 'true'
|
| 1674 |
|
| 1675 |
try:
|
| 1676 |
price = round(float(price_str), 2)
|
|
|
|
| 1696 |
break
|
| 1697 |
if photo and photo.filename:
|
| 1698 |
try:
|
| 1699 |
+
ext = os.path.splitext(photo.filename)[1]
|
| 1700 |
photo_filename = secure_filename(f"{product_to_edit['name'].replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
|
| 1701 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1702 |
photo.save(temp_path)
|
|
|
|
| 1723 |
if old_photos:
|
| 1724 |
logging.info(f"Попытка удаления старых фото: {old_photos}")
|
| 1725 |
try:
|
| 1726 |
+
api = HfApi()
|
| 1727 |
+
paths_to_delete = [f"photos/{p}" for p in old_photos if p] # Ensure no empty strings
|
| 1728 |
+
if paths_to_delete:
|
| 1729 |
+
api.delete_files(
|
| 1730 |
+
repo_id=REPO_ID,
|
| 1731 |
+
paths_in_repo=paths_to_delete,
|
| 1732 |
+
repo_type="dataset",
|
| 1733 |
+
token=HF_TOKEN_WRITE,
|
| 1734 |
+
commit_message=f"Delete old photos for product {product_to_edit['name']}",
|
| 1735 |
+
# ignore_patterns=None, # Ensure it tries to delete
|
| 1736 |
+
# delete_patterns=None
|
| 1737 |
+
)
|
| 1738 |
+
logging.info(f"Старые фото для товара {product_to_edit['name']} удалены с HF.")
|
| 1739 |
+
else:
|
| 1740 |
+
logging.info("Нет старых фото для удаления с HF.")
|
| 1741 |
+
|
| 1742 |
except Exception as e:
|
| 1743 |
+
# Handle cases where files might not exist on HF already
|
| 1744 |
+
if "404" in str(e) or "not found on repo" in str(e).lower():
|
| 1745 |
+
logging.warning(f"Некоторые старые фото не найдены на HF при попытке удаления: {e}")
|
| 1746 |
+
flash("Некоторые ста��ые фото не были найдены на сервере для удаления.", "warning")
|
| 1747 |
+
else:
|
| 1748 |
+
logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True)
|
| 1749 |
+
flash("Не удалось удалить старые фотографии с сервера.", "warning")
|
| 1750 |
+
|
| 1751 |
product_to_edit['photos'] = new_photos_list
|
| 1752 |
flash("Фотографии товара успешно обновлены.", "success")
|
| 1753 |
elif uploaded_count == 0 and any(f.filename for f in photos_files):
|
| 1754 |
flash("Не удалось загрузить новые фотографии.", "error")
|
| 1755 |
|
| 1756 |
+
# products.sort(key=lambda x: x.get('name', '').lower()) # Re-sort after edit
|
| 1757 |
+
products.sort(key=lambda p: (not p.get('is_featured', False), p.get('name', '').lower()))
|
| 1758 |
save_data(data)
|
| 1759 |
+
logging.info(f"Товар '{original_name}' (индекс {index}) обновлен на '{product_to_edit['name']}' (In Stock: {product_to_edit['in_stock']}, Featured: {product_to_edit['is_featured']}).")
|
| 1760 |
flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
|
| 1761 |
|
| 1762 |
|
| 1763 |
elif action == 'delete_product':
|
| 1764 |
+
index_str = request.form.get('index')
|
| 1765 |
if index_str is None:
|
| 1766 |
flash("Ошибка удаления: индекс товара не передан.", 'error')
|
| 1767 |
return redirect(url_for('admin'))
|
| 1768 |
try:
|
| 1769 |
index = int(index_str)
|
| 1770 |
+
if not (0 <= index < len(products)): raise IndexError("Индекс вне диапазона")
|
| 1771 |
+
deleted_product = products.pop(index)
|
|
|
|
| 1772 |
product_name = deleted_product.get('name', 'N/A')
|
| 1773 |
|
| 1774 |
photos_to_delete = deleted_product.get('photos', [])
|
|
|
|
| 1776 |
logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}")
|
| 1777 |
try:
|
| 1778 |
api = HfApi()
|
| 1779 |
+
paths_to_delete = [f"photos/{p}" for p in photos_to_delete if p]
|
| 1780 |
+
if paths_to_delete:
|
| 1781 |
+
api.delete_files(
|
| 1782 |
+
repo_id=REPO_ID,
|
| 1783 |
+
paths_in_repo=paths_to_delete,
|
| 1784 |
+
repo_type="dataset",
|
| 1785 |
+
token=HF_TOKEN_WRITE,
|
| 1786 |
+
commit_message=f"Delete photos for deleted product {product_name}"
|
| 1787 |
+
)
|
| 1788 |
+
logging.info(f"Фото товара '{product_name}' удалены с HF.")
|
| 1789 |
+
else:
|
| 1790 |
+
logging.info("Нет фото для удаления с HF.")
|
| 1791 |
except Exception as e:
|
| 1792 |
+
if "404" in str(e) or "not found on repo" in str(e).lower():
|
| 1793 |
+
logging.warning(f"Некоторые фото не найдены на HF при удалении товара '{product_name}': {e}")
|
| 1794 |
+
flash(f"Фото для удаленного товара '{product_name}' не найдены на сервере.", "warning")
|
| 1795 |
+
else:
|
| 1796 |
+
logging.error(f"Ошибка при удалении фото {photos_to_delete} для товара '{product_name}' с HF: {e}", exc_info=True)
|
| 1797 |
+
flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning")
|
| 1798 |
|
| 1799 |
save_data(data)
|
| 1800 |
+
logging.info(f"Товар '{product_name}' (индекс {index}) удален.")
|
| 1801 |
flash(f"Товар '{product_name}' удален.", 'success')
|
| 1802 |
+
except (ValueError, IndexError):
|
| 1803 |
+
flash(f"Ошибка удаления: неверный индекс товара '{index_str}'.", 'error')
|
| 1804 |
|
| 1805 |
|
| 1806 |
elif action == 'add_user':
|
|
|
|
| 1851 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1852 |
return redirect(url_for('admin'))
|
| 1853 |
|
| 1854 |
+
# Sort products for display in admin panel as well
|
| 1855 |
+
products.sort(key=lambda p: (not p.get('is_featured', False), p.get('name', '').lower()))
|
|
|
|
|
|
|
|
|
|
| 1856 |
categories.sort()
|
|
|
|
| 1857 |
sorted_users = dict(sorted(users.items()))
|
| 1858 |
|
| 1859 |
return render_template_string(
|
| 1860 |
ADMIN_TEMPLATE,
|
| 1861 |
+
products=products, # Pass the full list to admin
|
| 1862 |
categories=categories,
|
| 1863 |
users=sorted_users,
|
| 1864 |
repo_id=REPO_ID,
|
|
|
|
| 1867 |
|
| 1868 |
@app.route('/force_upload', methods=['POST'])
|
| 1869 |
def force_upload():
|
| 1870 |
+
# Add auth check
|
| 1871 |
+
if 'user' not in session:
|
| 1872 |
+
flash("Доступ запрещен.", "error")
|
| 1873 |
+
return redirect(url_for('login'))
|
| 1874 |
logging.info("Запущена принудительная загрузка данных на Hugging Face...")
|
| 1875 |
try:
|
| 1876 |
upload_db_to_hf()
|
|
|
|
| 1882 |
|
| 1883 |
@app.route('/force_download', methods=['POST'])
|
| 1884 |
def force_download():
|
| 1885 |
+
# Add auth check
|
| 1886 |
+
if 'user' not in session:
|
| 1887 |
+
flash("Доступ запрещен.", "error")
|
| 1888 |
+
return redirect(url_for('login'))
|
| 1889 |
logging.info("Запущено принудительное скачивание данных с Hugging Face...")
|
| 1890 |
try:
|
| 1891 |
download_db_from_hf()
|
| 1892 |
+
# Reload data in memory after download
|
| 1893 |
+
global data, users
|
| 1894 |
+
data = load_data()
|
| 1895 |
+
users = load_users()
|
| 1896 |
+
flash("Данные успешно скачаны с Hugging Face. Локальные файлы и данные в памяти обновлены.", 'success')
|
| 1897 |
except Exception as e:
|
| 1898 |
logging.error(f"Ошибка при принудительном скачивании: {e}", exc_info=True)
|
| 1899 |
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
|
|
|
|
| 1901 |
|
| 1902 |
|
| 1903 |
if __name__ == '__main__':
|
| 1904 |
+
# Ensure static folder exists for favicon
|
| 1905 |
+
if not os.path.exists('static'):
|
| 1906 |
+
os.makedirs('static')
|
| 1907 |
+
# Create a dummy favicon if it doesn't exist to avoid initial 404s
|
| 1908 |
+
favicon_path = 'static/favicon.ico'
|
| 1909 |
+
if not os.path.exists(favicon_path):
|
| 1910 |
+
try:
|
| 1911 |
+
# Create a minimal ICO file (1x1 pixel transparent)
|
| 1912 |
+
with open(favicon_path, 'wb') as f:
|
| 1913 |
+
f.write(b'\x00\x00\x01\x00\x01\x00\x01\x01\x00\x00\x01\x00\x18\x00\x1c\x00\x00\x00\x16\x00\x00\x00(\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00')
|
| 1914 |
+
logging.info(f"Создан пустой файл {favicon_path}")
|
| 1915 |
+
except Exception as e:
|
| 1916 |
+
logging.warning(f"Не удалось создать пустой favicon.ico: {e}")
|
| 1917 |
+
|
| 1918 |
+
|
| 1919 |
load_data()
|
| 1920 |
load_users()
|
| 1921 |
|
|
|
|
| 1928 |
|
| 1929 |
port = int(os.environ.get('PORT', 7860))
|
| 1930 |
logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
|
| 1931 |
+
# Set debug=False for production/deployment
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1932 |
app.run(debug=False, host='0.0.0.0', port=port)
|
| 1933 |
|