Update app.py
Browse files
app.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
|
|
|
|
|
| 1 |
from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash
|
| 2 |
import json
|
| 3 |
import os
|
|
@@ -206,10 +208,13 @@ def periodic_backup():
|
|
| 206 |
@app.route('/')
|
| 207 |
def catalog():
|
| 208 |
data = load_data()
|
| 209 |
-
|
| 210 |
categories = data.get('categories', [])
|
| 211 |
is_authenticated = 'user' in session
|
| 212 |
|
|
|
|
|
|
|
|
|
|
| 213 |
catalog_html = '''
|
| 214 |
<!DOCTYPE html>
|
| 215 |
<html lang="ru">
|
|
@@ -252,8 +257,7 @@ def catalog():
|
|
| 252 |
.category-filter.active, .category-filter:hover { background-color: #1C6758; color: white; border-color: #1C6758; box-shadow: 0 2px 10px rgba(28, 103, 88, 0.3); }
|
| 253 |
body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover { background-color: #3D8361; border-color: #3D8361; color: #1a2b26; box-shadow: 0 2px 10px rgba(61, 131, 97, 0.4); }
|
| 254 |
|
| 255 |
-
.products-grid { display: grid; grid-template-columns: repeat(
|
| 256 |
-
@media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
|
| 257 |
.product { background: #fff; border-radius: 15px; padding: 0; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; height: 100%; border: 1px solid #e1f0e9;}
|
| 258 |
body.dark-mode .product { background: #253f37; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #2c4a41; }
|
| 259 |
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
|
|
@@ -316,6 +320,9 @@ def catalog():
|
|
| 316 |
.notification.show { opacity: 1;}
|
| 317 |
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #5e6e68; }
|
| 318 |
body.dark-mode .no-results-message { color: #8aa39a; }
|
|
|
|
|
|
|
|
|
|
| 319 |
</style>
|
| 320 |
</head>
|
| 321 |
<body>
|
|
@@ -354,6 +361,9 @@ def catalog():
|
|
| 354 |
data-name="{{ product['name']|lower }}"
|
| 355 |
data-description="{{ product.get('description', '')|lower }}"
|
| 356 |
data-category="{{ product.get('category', 'Без категории') }}">
|
|
|
|
|
|
|
|
|
|
| 357 |
<div class="product-image">
|
| 358 |
{% if product.get('photos') and product['photos']|length > 0 %}
|
| 359 |
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
|
|
@@ -439,6 +449,7 @@ def catalog():
|
|
| 439 |
const repoId = '{{ repo_id }}';
|
| 440 |
const currencyCode = '{{ currency_code }}';
|
| 441 |
const isAuthenticated = {{ is_authenticated|tojson }};
|
|
|
|
| 442 |
let selectedProductIndex = null;
|
| 443 |
let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
|
| 444 |
|
|
@@ -703,28 +714,41 @@ def catalog():
|
|
| 703 |
return;
|
| 704 |
}
|
| 705 |
let total = 0;
|
| 706 |
-
let orderText = "
|
|
|
|
|
|
|
|
|
|
| 707 |
cart.forEach((item, index) => {
|
| 708 |
const itemTotal = item.price * item.quantity;
|
| 709 |
total += itemTotal;
|
| 710 |
-
const colorText = item.color !== 'N/A' ? ` (
|
| 711 |
-
orderText += `${index + 1}.
|
|
|
|
|
|
|
|
|
|
| 712 |
});
|
| 713 |
-
orderText +=
|
|
|
|
|
|
|
| 714 |
|
| 715 |
-
const userInfo = {{ session.get('user_info', {})|tojson }};
|
| 716 |
if (userInfo && userInfo.login) {
|
| 717 |
-
orderText +=
|
|
|
|
| 718 |
orderText += `Логин: ${userInfo.login}%0A`;
|
|
|
|
|
|
|
|
|
|
| 719 |
orderText += `Страна: ${userInfo.country || 'Не указана'}%0A`;
|
| 720 |
orderText += `Город: ${userInfo.city || 'Не указан'}%0A`;
|
| 721 |
} else {
|
| 722 |
-
orderText +=
|
| 723 |
}
|
|
|
|
| 724 |
|
| 725 |
const now = new Date();
|
| 726 |
-
const dateTimeString = now.toLocaleString('ru-RU');
|
| 727 |
-
orderText +=
|
|
|
|
| 728 |
|
| 729 |
const whatsappNumber = "996997703090";
|
| 730 |
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
|
|
@@ -757,11 +781,16 @@ def catalog():
|
|
| 757 |
}
|
| 758 |
});
|
| 759 |
|
| 760 |
-
if (visibleProducts === 0 &&
|
| 761 |
const p = document.createElement('p');
|
| 762 |
p.className = 'no-results-message';
|
| 763 |
p.textContent = 'По вашему запросу товары не найдены.';
|
| 764 |
grid.appendChild(p);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
}
|
| 766 |
}
|
| 767 |
|
|
@@ -778,6 +807,7 @@ def catalog():
|
|
| 778 |
filterProducts();
|
| 779 |
});
|
| 780 |
});
|
|
|
|
| 781 |
}
|
| 782 |
|
| 783 |
function showNotification(message, duration = 3000) {
|
|
@@ -824,7 +854,7 @@ def catalog():
|
|
| 824 |
'''
|
| 825 |
return render_template_string(
|
| 826 |
catalog_html,
|
| 827 |
-
products=
|
| 828 |
categories=categories,
|
| 829 |
repo_id=REPO_ID,
|
| 830 |
is_authenticated=is_authenticated,
|
|
@@ -837,13 +867,18 @@ def catalog():
|
|
| 837 |
@app.route('/product/<int:index>')
|
| 838 |
def product_detail(index):
|
| 839 |
data = load_data()
|
| 840 |
-
|
|
|
|
|
|
|
|
|
|
| 841 |
is_authenticated = 'user' in session
|
| 842 |
try:
|
| 843 |
-
product =
|
|
|
|
|
|
|
| 844 |
except IndexError:
|
| 845 |
-
logging.warning(f"Попытка доступа к несуществующему продукту с индексом {index}")
|
| 846 |
-
return "Товар не найден", 404
|
| 847 |
|
| 848 |
detail_html = '''
|
| 849 |
<div style="padding: 10px;">
|
|
@@ -955,7 +990,8 @@ def login():
|
|
| 955 |
'first_name': user_info.get('first_name', ''),
|
| 956 |
'last_name': user_info.get('last_name', ''),
|
| 957 |
'country': user_info.get('country', ''),
|
| 958 |
-
'city': user_info.get('city', '')
|
|
|
|
| 959 |
}
|
| 960 |
logging.info(f"Пользователь {login} успешно вошел в систему.")
|
| 961 |
login_response_html = f'''
|
|
@@ -997,7 +1033,8 @@ def auto_login():
|
|
| 997 |
'first_name': user_info.get('first_name', ''),
|
| 998 |
'last_name': user_info.get('last_name', ''),
|
| 999 |
'country': user_info.get('country', ''),
|
| 1000 |
-
'city': user_info.get('city', '')
|
|
|
|
| 1001 |
}
|
| 1002 |
logging.info(f"Автоматический вход для пользователя {login} выполнен.")
|
| 1003 |
return "OK", 200
|
|
@@ -1043,11 +1080,13 @@ ADMIN_TEMPLATE = '''
|
|
| 1043 |
.section { margin-bottom: 30px; padding: 20px; background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; }
|
| 1044 |
form { margin-bottom: 20px; }
|
| 1045 |
label { font-weight: 500; margin-top: 10px; display: block; color: #44524c; font-size: 0.9rem;}
|
| 1046 |
-
input[type="text"], input[type="number"], input[type="password"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #c4d9d1; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; }
|
| 1047 |
input:focus, textarea:focus, select:focus { border-color: #1C6758; outline: none; box-shadow: 0 0 0 2px rgba(28, 103, 88, 0.1); }
|
| 1048 |
textarea { min-height: 80px; resize: vertical; }
|
| 1049 |
input[type="file"] { padding: 8px; background-color: #f0f9f4; cursor: pointer; border: 1px solid #c4d9d1;}
|
| 1050 |
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #e0f0e9; border: 1px solid #c4d9d1; cursor: pointer; margin-right: 10px;}
|
|
|
|
|
|
|
| 1051 |
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;}
|
| 1052 |
button:hover, .button:hover { background-color: #164B41; }
|
| 1053 |
button:active, .button:active { transform: scale(0.98); }
|
|
@@ -1061,7 +1100,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1061 |
.item p { margin: 5px 0; font-size: 0.9rem; color: #44524c; }
|
| 1062 |
.item strong { color: #2d332f; }
|
| 1063 |
.item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
|
| 1064 |
-
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
|
| 1065 |
.item-actions button:not(.delete-button) { background-color: #1C6758; }
|
| 1066 |
.item-actions button:not(.delete-button):hover { background-color: #164B41; }
|
| 1067 |
.edit-form-container { margin-top: 15px; padding: 20px; background: #f0f9f4; border: 1px dashed #c4d9d1; border-radius: 6px; display: none; }
|
|
@@ -1087,6 +1126,10 @@ ADMIN_TEMPLATE = '''
|
|
| 1087 |
.message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
|
| 1088 |
.message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
|
| 1089 |
.message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1090 |
</style>
|
| 1091 |
</head>
|
| 1092 |
<body>
|
|
@@ -1165,12 +1208,14 @@ ADMIN_TEMPLATE = '''
|
|
| 1165 |
<label for="login">Логин *:</label>
|
| 1166 |
<input type="text" id="login" name="login" required>
|
| 1167 |
<label for="password">Пароль *:</label>
|
| 1168 |
-
<input type="password" id="password" name="password" required title="Пароль будет сохранен в открытом
|
| 1169 |
-
<p style="font-size: 0.8rem; color: #777;">Логин и пароль
|
| 1170 |
<label for="first_name">Имя:</label>
|
| 1171 |
<input type="text" id="first_name" name="first_name">
|
| 1172 |
<label for="last_name">Фамилия:</label>
|
| 1173 |
<input type="text" id="last_name" name="last_name">
|
|
|
|
|
|
|
| 1174 |
<label for="country">Страна:</label>
|
| 1175 |
<input type="text" id="country" name="country">
|
| 1176 |
<label for="city">Город:</label>
|
|
@@ -1187,6 +1232,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1187 |
<div class="item">
|
| 1188 |
<p><strong>Логин:</strong> {{ login }}</p>
|
| 1189 |
<p><strong>Имя:</strong> {{ user_data.get('first_name', 'N/A') }} {{ user_data.get('last_name', '') }}</p>
|
|
|
|
| 1190 |
<p><strong>Локация:</strong> {{ user_data.get('city', 'N/A') }}, {{ user_data.get('country', 'N/A') }}</p>
|
| 1191 |
<div class="item-actions">
|
| 1192 |
<form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя \'{{ login }}\'?');">
|
|
@@ -1237,6 +1283,15 @@ ADMIN_TEMPLATE = '''
|
|
| 1237 |
</div>
|
| 1238 |
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button>
|
| 1239 |
<br>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1240 |
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button>
|
| 1241 |
</form>
|
| 1242 |
</div>
|
|
@@ -1258,7 +1313,17 @@ ADMIN_TEMPLATE = '''
|
|
| 1258 |
{% endif %}
|
| 1259 |
</div>
|
| 1260 |
<div style="flex-grow: 1;">
|
| 1261 |
-
<h3 style="margin-top: 0; margin-bottom: 5px; color: #2d332f;">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1262 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 1263 |
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 1264 |
<p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
|
|
@@ -1328,6 +1393,15 @@ ADMIN_TEMPLATE = '''
|
|
| 1328 |
</div>
|
| 1329 |
<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>
|
| 1330 |
<br>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1331 |
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button>
|
| 1332 |
</form>
|
| 1333 |
</div>
|
|
@@ -1370,7 +1444,19 @@ ADMIN_TEMPLATE = '''
|
|
| 1370 |
const group = button.closest('.color-input-group');
|
| 1371 |
if (group) {
|
| 1372 |
const container = group.parentNode;
|
|
|
|
|
|
|
| 1373 |
group.remove();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1374 |
} else {
|
| 1375 |
console.warn("Не удалось найти родительский .color-input-group для кнопки удаления");
|
| 1376 |
}
|
|
@@ -1431,6 +1517,9 @@ def admin():
|
|
| 1431 |
category = request.form.get('category')
|
| 1432 |
photos_files = request.files.getlist('photos')
|
| 1433 |
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
|
|
|
|
|
|
|
|
|
| 1434 |
|
| 1435 |
if not name or not price_str:
|
| 1436 |
flash("Название и цена товара обязательны.", 'error')
|
|
@@ -1489,10 +1578,11 @@ def admin():
|
|
| 1489 |
new_product = {
|
| 1490 |
'name': name, 'price': price, 'description': description,
|
| 1491 |
'category': category if category in categories else 'Без категории',
|
| 1492 |
-
'photos': photos_list, 'colors': colors
|
|
|
|
| 1493 |
}
|
| 1494 |
products.append(new_product)
|
| 1495 |
-
|
| 1496 |
save_data(data)
|
| 1497 |
logging.info(f"Товар '{name}' добавлен.")
|
| 1498 |
flash(f"Товар '{name}' успешно добавлен.", 'success')
|
|
@@ -1505,9 +1595,13 @@ def admin():
|
|
| 1505 |
|
| 1506 |
try:
|
| 1507 |
index = int(index_str)
|
| 1508 |
-
|
| 1509 |
-
|
|
|
|
|
|
|
|
|
|
| 1510 |
original_name = product_to_edit.get('name', 'N/A')
|
|
|
|
| 1511 |
except (ValueError, IndexError):
|
| 1512 |
flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
|
| 1513 |
return redirect(url_for('admin'))
|
|
@@ -1518,6 +1612,9 @@ def admin():
|
|
| 1518 |
category = request.form.get('category')
|
| 1519 |
product_to_edit['category'] = category if category in categories else 'Без категории'
|
| 1520 |
product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
|
|
|
|
|
|
|
|
|
| 1521 |
|
| 1522 |
try:
|
| 1523 |
price = round(float(price_str), 2)
|
|
@@ -1586,7 +1683,7 @@ def admin():
|
|
| 1586 |
elif uploaded_count == 0 and any(f.filename for f in photos_files):
|
| 1587 |
flash("Не удалось загрузить новые фотографии.", "error")
|
| 1588 |
|
| 1589 |
-
|
| 1590 |
save_data(data)
|
| 1591 |
logging.info(f"Товар '{original_name}' (индекс {index}) обновлен на '{product_to_edit['name']}'.")
|
| 1592 |
flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
|
|
@@ -1599,8 +1696,9 @@ def admin():
|
|
| 1599 |
return redirect(url_for('admin'))
|
| 1600 |
try:
|
| 1601 |
index = int(index_str)
|
| 1602 |
-
|
| 1603 |
-
|
|
|
|
| 1604 |
product_name = deleted_product.get('name', 'N/A')
|
| 1605 |
|
| 1606 |
photos_to_delete = deleted_product.get('photos', [])
|
|
@@ -1632,6 +1730,7 @@ def admin():
|
|
| 1632 |
password = request.form.get('password', '').strip()
|
| 1633 |
first_name = request.form.get('first_name', '').strip()
|
| 1634 |
last_name = request.form.get('last_name', '').strip()
|
|
|
|
| 1635 |
country = request.form.get('country', '').strip()
|
| 1636 |
city = request.form.get('city', '').strip()
|
| 1637 |
|
|
@@ -1645,6 +1744,7 @@ def admin():
|
|
| 1645 |
users[login] = {
|
| 1646 |
'password': password,
|
| 1647 |
'first_name': first_name, 'last_name': last_name,
|
|
|
|
| 1648 |
'country': country, 'city': city
|
| 1649 |
}
|
| 1650 |
save_users(users)
|
|
@@ -1673,13 +1773,21 @@ def admin():
|
|
| 1673 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1674 |
return redirect(url_for('admin'))
|
| 1675 |
|
| 1676 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1677 |
categories.sort()
|
| 1678 |
sorted_users = dict(sorted(users.items()))
|
| 1679 |
|
| 1680 |
return render_template_string(
|
| 1681 |
ADMIN_TEMPLATE,
|
| 1682 |
-
products=
|
| 1683 |
categories=categories,
|
| 1684 |
users=sorted_users,
|
| 1685 |
repo_id=REPO_ID,
|
|
@@ -1722,4 +1830,5 @@ if __name__ == '__main__':
|
|
| 1722 |
|
| 1723 |
port = int(os.environ.get('PORT', 7860))
|
| 1724 |
logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
|
| 1725 |
-
app.run(debug=False, host='0.0.0.0', port=port)
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash
|
| 4 |
import json
|
| 5 |
import os
|
|
|
|
| 208 |
@app.route('/')
|
| 209 |
def catalog():
|
| 210 |
data = load_data()
|
| 211 |
+
all_products = data.get('products', [])
|
| 212 |
categories = data.get('categories', [])
|
| 213 |
is_authenticated = 'user' in session
|
| 214 |
|
| 215 |
+
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 216 |
+
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 217 |
+
|
| 218 |
catalog_html = '''
|
| 219 |
<!DOCTYPE html>
|
| 220 |
<html lang="ru">
|
|
|
|
| 257 |
.category-filter.active, .category-filter:hover { background-color: #1C6758; color: white; border-color: #1C6758; box-shadow: 0 2px 10px rgba(28, 103, 88, 0.3); }
|
| 258 |
body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover { background-color: #3D8361; border-color: #3D8361; color: #1a2b26; box-shadow: 0 2px 10px rgba(61, 131, 97, 0.4); }
|
| 259 |
|
| 260 |
+
.products-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; padding: 10px; }
|
|
|
|
| 261 |
.product { background: #fff; border-radius: 15px; padding: 0; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; height: 100%; border: 1px solid #e1f0e9;}
|
| 262 |
body.dark-mode .product { background: #253f37; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #2c4a41; }
|
| 263 |
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
|
|
|
|
| 320 |
.notification.show { opacity: 1;}
|
| 321 |
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #5e6e68; }
|
| 322 |
body.dark-mode .no-results-message { color: #8aa39a; }
|
| 323 |
+
.top-product-indicator { position: absolute; top: 8px; right: 8px; background-color: rgba(255, 215, 0, 0.8); color: #333; padding: 2px 6px; font-size: 0.7rem; border-radius: 4px; font-weight: bold; z-index: 10; backdrop-filter: blur(2px); }
|
| 324 |
+
.product { position: relative; }
|
| 325 |
+
|
| 326 |
</style>
|
| 327 |
</head>
|
| 328 |
<body>
|
|
|
|
| 361 |
data-name="{{ product['name']|lower }}"
|
| 362 |
data-description="{{ product.get('description', '')|lower }}"
|
| 363 |
data-category="{{ product.get('category', 'Без категории') }}">
|
| 364 |
+
{% if product.get('is_top', False) %}
|
| 365 |
+
<span class="top-product-indicator"><i class="fas fa-star"></i> Топ</span>
|
| 366 |
+
{% endif %}
|
| 367 |
<div class="product-image">
|
| 368 |
{% if product.get('photos') and product['photos']|length > 0 %}
|
| 369 |
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
|
|
|
|
| 449 |
const repoId = '{{ repo_id }}';
|
| 450 |
const currencyCode = '{{ currency_code }}';
|
| 451 |
const isAuthenticated = {{ is_authenticated|tojson }};
|
| 452 |
+
const userInfo = {{ session.get('user_info', {})|tojson }};
|
| 453 |
let selectedProductIndex = null;
|
| 454 |
let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
|
| 455 |
|
|
|
|
| 714 |
return;
|
| 715 |
}
|
| 716 |
let total = 0;
|
| 717 |
+
let orderText = "🛍️ *Новый Заказ от Soola Cosmetics* 🛍️%0A%0A";
|
| 718 |
+
orderText += "----------------------------------------%0A";
|
| 719 |
+
orderText += "*Детали заказа:*%0A";
|
| 720 |
+
orderText += "----------------------------------------%0A";
|
| 721 |
cart.forEach((item, index) => {
|
| 722 |
const itemTotal = item.price * item.quantity;
|
| 723 |
total += itemTotal;
|
| 724 |
+
const colorText = item.color !== 'N/A' ? ` (${item.color})` : '';
|
| 725 |
+
orderText += `${index + 1}. *${item.name}*${colorText}%0A`;
|
| 726 |
+
orderText += ` Кол-во: ${item.quantity}%0A`;
|
| 727 |
+
orderText += ` Цена: ${item.price.toFixed(2)} ${currencyCode}%0A`;
|
| 728 |
+
orderText += ` *Сумма: ${itemTotal.toFixed(2)} ${currencyCode}*%0A%0A`;
|
| 729 |
});
|
| 730 |
+
orderText += "----------------------------------------%0A";
|
| 731 |
+
orderText += `*ИТОГО: ${total.toFixed(2)} ${currencyCode}*%0A`;
|
| 732 |
+
orderText += "----------------------------------------%0A%0A";
|
| 733 |
|
|
|
|
| 734 |
if (userInfo && userInfo.login) {
|
| 735 |
+
orderText += "*Данные клиента:*%0A";
|
| 736 |
+
orderText += `Имя: ${userInfo.first_name || ''} ${userInfo.last_name || ''}%0A`;
|
| 737 |
orderText += `Логин: ${userInfo.login}%0A`;
|
| 738 |
+
if (userInfo.phone) {
|
| 739 |
+
orderText += `Телефон: ${userInfo.phone}%0A`;
|
| 740 |
+
}
|
| 741 |
orderText += `Страна: ${userInfo.country || 'Не указана'}%0A`;
|
| 742 |
orderText += `Город: ${userInfo.city || 'Не указан'}%0A`;
|
| 743 |
} else {
|
| 744 |
+
orderText += "*Клиент не авторизован*%0A";
|
| 745 |
}
|
| 746 |
+
orderText += "----------------------------------------%0A%0A";
|
| 747 |
|
| 748 |
const now = new Date();
|
| 749 |
+
const dateTimeString = now.toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
| 750 |
+
orderText += `Дата заказа: ${dateTimeString}%0A`;
|
| 751 |
+
orderText += `_Сформировано автоматически_`;
|
| 752 |
|
| 753 |
const whatsappNumber = "996997703090";
|
| 754 |
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
|
|
|
|
| 781 |
}
|
| 782 |
});
|
| 783 |
|
| 784 |
+
if (visibleProducts === 0 && products.length > 0) {
|
| 785 |
const p = document.createElement('p');
|
| 786 |
p.className = 'no-results-message';
|
| 787 |
p.textContent = 'По вашему запросу товары не найдены.';
|
| 788 |
grid.appendChild(p);
|
| 789 |
+
} else if (products.length === 0 && !grid.querySelector('.no-results-message')) {
|
| 790 |
+
const p = document.createElement('p');
|
| 791 |
+
p.className = 'no-results-message';
|
| 792 |
+
p.textContent = 'Товары пока не добавлены.';
|
| 793 |
+
grid.appendChild(p);
|
| 794 |
}
|
| 795 |
}
|
| 796 |
|
|
|
|
| 807 |
filterProducts();
|
| 808 |
});
|
| 809 |
});
|
| 810 |
+
filterProducts(); // Initial filter on load
|
| 811 |
}
|
| 812 |
|
| 813 |
function showNotification(message, duration = 3000) {
|
|
|
|
| 854 |
'''
|
| 855 |
return render_template_string(
|
| 856 |
catalog_html,
|
| 857 |
+
products=products_sorted,
|
| 858 |
categories=categories,
|
| 859 |
repo_id=REPO_ID,
|
| 860 |
is_authenticated=is_authenticated,
|
|
|
|
| 867 |
@app.route('/product/<int:index>')
|
| 868 |
def product_detail(index):
|
| 869 |
data = load_data()
|
| 870 |
+
all_products = data.get('products', [])
|
| 871 |
+
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 872 |
+
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 873 |
+
|
| 874 |
is_authenticated = 'user' in session
|
| 875 |
try:
|
| 876 |
+
product = products_sorted[index]
|
| 877 |
+
if not product.get('in_stock', True):
|
| 878 |
+
raise IndexError("Товар не в наличии")
|
| 879 |
except IndexError:
|
| 880 |
+
logging.warning(f"Попытка доступа к несуществующему или отсутствующему продукту с индексом {index}")
|
| 881 |
+
return "Товар не найден или отсутствует в наличии.", 404
|
| 882 |
|
| 883 |
detail_html = '''
|
| 884 |
<div style="padding: 10px;">
|
|
|
|
| 990 |
'first_name': user_info.get('first_name', ''),
|
| 991 |
'last_name': user_info.get('last_name', ''),
|
| 992 |
'country': user_info.get('country', ''),
|
| 993 |
+
'city': user_info.get('city', ''),
|
| 994 |
+
'phone': user_info.get('phone', '')
|
| 995 |
}
|
| 996 |
logging.info(f"Пользователь {login} успешно вошел в систему.")
|
| 997 |
login_response_html = f'''
|
|
|
|
| 1033 |
'first_name': user_info.get('first_name', ''),
|
| 1034 |
'last_name': user_info.get('last_name', ''),
|
| 1035 |
'country': user_info.get('country', ''),
|
| 1036 |
+
'city': user_info.get('city', ''),
|
| 1037 |
+
'phone': user_info.get('phone', '')
|
| 1038 |
}
|
| 1039 |
logging.info(f"Автоматический вход для пользователя {login} выполнен.")
|
| 1040 |
return "OK", 200
|
|
|
|
| 1080 |
.section { margin-bottom: 30px; padding: 20px; background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; }
|
| 1081 |
form { margin-bottom: 20px; }
|
| 1082 |
label { font-weight: 500; margin-top: 10px; display: block; color: #44524c; font-size: 0.9rem;}
|
| 1083 |
+
input[type="text"], input[type="number"], input[type="password"], input[type="tel"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #c4d9d1; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; }
|
| 1084 |
input:focus, textarea:focus, select:focus { border-color: #1C6758; outline: none; box-shadow: 0 0 0 2px rgba(28, 103, 88, 0.1); }
|
| 1085 |
textarea { min-height: 80px; resize: vertical; }
|
| 1086 |
input[type="file"] { padding: 8px; background-color: #f0f9f4; cursor: pointer; border: 1px solid #c4d9d1;}
|
| 1087 |
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #e0f0e9; border: 1px solid #c4d9d1; cursor: pointer; margin-right: 10px;}
|
| 1088 |
+
input[type="checkbox"] { margin-right: 5px; vertical-align: middle; }
|
| 1089 |
+
label.inline-label { display: inline-block; margin-top: 10px; font-weight: normal; }
|
| 1090 |
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;}
|
| 1091 |
button:hover, .button:hover { background-color: #164B41; }
|
| 1092 |
button:active, .button:active { transform: scale(0.98); }
|
|
|
|
| 1100 |
.item p { margin: 5px 0; font-size: 0.9rem; color: #44524c; }
|
| 1101 |
.item strong { color: #2d332f; }
|
| 1102 |
.item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
|
| 1103 |
+
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
| 1104 |
.item-actions button:not(.delete-button) { background-color: #1C6758; }
|
| 1105 |
.item-actions button:not(.delete-button):hover { background-color: #164B41; }
|
| 1106 |
.edit-form-container { margin-top: 15px; padding: 20px; background: #f0f9f4; border: 1px dashed #c4d9d1; border-radius: 6px; display: none; }
|
|
|
|
| 1126 |
.message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
|
| 1127 |
.message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
|
| 1128 |
.message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
|
| 1129 |
+
.status-indicator { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 500; margin-left: 10px; vertical-align: middle; }
|
| 1130 |
+
.status-indicator.in-stock { background-color: #c6f6d5; color: #2f855a; }
|
| 1131 |
+
.status-indicator.out-of-stock { background-color: #fed7d7; color: #c53030; }
|
| 1132 |
+
.status-indicator.top-product { background-color: #feebc8; color: #9c4221; margin-left: 5px;}
|
| 1133 |
</style>
|
| 1134 |
</head>
|
| 1135 |
<body>
|
|
|
|
| 1208 |
<label for="login">Логин *:</label>
|
| 1209 |
<input type="text" id="login" name="login" required>
|
| 1210 |
<label for="password">Пароль *:</label>
|
| 1211 |
+
<input type="password" id="password" name="password" required title="Пароль будет сохранен в открытом виде.">
|
| 1212 |
+
<p style="font-size: 0.8rem; color: #777;">Логин и пароль обязательны.</p>
|
| 1213 |
<label for="first_name">Имя:</label>
|
| 1214 |
<input type="text" id="first_name" name="first_name">
|
| 1215 |
<label for="last_name">Фамилия:</label>
|
| 1216 |
<input type="text" id="last_name" name="last_name">
|
| 1217 |
+
<label for="phone">Телефон:</label>
|
| 1218 |
+
<input type="tel" id="phone" name="phone">
|
| 1219 |
<label for="country">Страна:</label>
|
| 1220 |
<input type="text" id="country" name="country">
|
| 1221 |
<label for="city">Город:</label>
|
|
|
|
| 1232 |
<div class="item">
|
| 1233 |
<p><strong>Логин:</strong> {{ login }}</p>
|
| 1234 |
<p><strong>Имя:</strong> {{ user_data.get('first_name', 'N/A') }} {{ user_data.get('last_name', '') }}</p>
|
| 1235 |
+
<p><strong>Телефон:</strong> {{ user_data.get('phone', 'N/A') }}</p>
|
| 1236 |
<p><strong>Локация:</strong> {{ user_data.get('city', 'N/A') }}, {{ user_data.get('country', 'N/A') }}</p>
|
| 1237 |
<div class="item-actions">
|
| 1238 |
<form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя \'{{ login }}\'?');">
|
|
|
|
| 1283 |
</div>
|
| 1284 |
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button>
|
| 1285 |
<br>
|
| 1286 |
+
<div style="margin-top: 15px;">
|
| 1287 |
+
<input type="checkbox" id="add_in_stock" name="in_stock" checked>
|
| 1288 |
+
<label for="add_in_stock" class="inline-label">В наличии</label>
|
| 1289 |
+
</div>
|
| 1290 |
+
<div style="margin-top: 5px;">
|
| 1291 |
+
<input type="checkbox" id="add_is_top" name="is_top">
|
| 1292 |
+
<label for="add_is_top" class="inline-label">Топ товар (показывать наверху)</label>
|
| 1293 |
+
</div>
|
| 1294 |
+
<br>
|
| 1295 |
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button>
|
| 1296 |
</form>
|
| 1297 |
</div>
|
|
|
|
| 1313 |
{% endif %}
|
| 1314 |
</div>
|
| 1315 |
<div style="flex-grow: 1;">
|
| 1316 |
+
<h3 style="margin-top: 0; margin-bottom: 5px; color: #2d332f;">
|
| 1317 |
+
{{ product['name'] }}
|
| 1318 |
+
{% if product.get('in_stock', True) %}
|
| 1319 |
+
<span class="status-indicator in-stock">В наличии</span>
|
| 1320 |
+
{% else %}
|
| 1321 |
+
<span class="status-indicator out-of-stock">Нет в наличии</span>
|
| 1322 |
+
{% endif %}
|
| 1323 |
+
{% if product.get('is_top', False) %}
|
| 1324 |
+
<span class="status-indicator top-product"><i class="fas fa-star"></i> Топ</span>
|
| 1325 |
+
{% endif %}
|
| 1326 |
+
</h3>
|
| 1327 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 1328 |
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 1329 |
<p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
|
|
|
|
| 1393 |
</div>
|
| 1394 |
<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>
|
| 1395 |
<br>
|
| 1396 |
+
<div style="margin-top: 15px;">
|
| 1397 |
+
<input type="checkbox" id="edit_in_stock_{{ loop.index0 }}" name="in_stock" {% if product.get('in_stock', True) %}checked{% endif %}>
|
| 1398 |
+
<label for="edit_in_stock_{{ loop.index0 }}" class="inline-label">В наличии</label>
|
| 1399 |
+
</div>
|
| 1400 |
+
<div style="margin-top: 5px;">
|
| 1401 |
+
<input type="checkbox" id="edit_is_top_{{ loop.index0 }}" name="is_top" {% if product.get('is_top', False) %}checked{% endif %}>
|
| 1402 |
+
<label for="edit_is_top_{{ loop.index0 }}" class="inline-label">Топ товар</label>
|
| 1403 |
+
</div>
|
| 1404 |
+
<br>
|
| 1405 |
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button>
|
| 1406 |
</form>
|
| 1407 |
</div>
|
|
|
|
| 1444 |
const group = button.closest('.color-input-group');
|
| 1445 |
if (group) {
|
| 1446 |
const container = group.parentNode;
|
| 1447 |
+
// Only remove if it's not the last one (or handle adding a placeholder if it is)
|
| 1448 |
+
// For simplicity, let's allow removing all. Add logic if needed later.
|
| 1449 |
group.remove();
|
| 1450 |
+
// Optional: If container is now empty, add a placeholder input back
|
| 1451 |
+
if (container && container.children.length === 0) {
|
| 1452 |
+
const placeholderGroup = document.createElement('div');
|
| 1453 |
+
placeholderGroup.className = 'color-input-group';
|
| 1454 |
+
placeholderGroup.innerHTML = `
|
| 1455 |
+
<input type="text" name="colors" placeholder="Например: Цвет">
|
| 1456 |
+
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1457 |
+
`;
|
| 1458 |
+
container.appendChild(placeholderGroup);
|
| 1459 |
+
}
|
| 1460 |
} else {
|
| 1461 |
console.warn("Не удалось найти родительский .color-input-group для кнопки удаления");
|
| 1462 |
}
|
|
|
|
| 1517 |
category = request.form.get('category')
|
| 1518 |
photos_files = request.files.getlist('photos')
|
| 1519 |
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1520 |
+
in_stock = 'in_stock' in request.form
|
| 1521 |
+
is_top = 'is_top' in request.form
|
| 1522 |
+
|
| 1523 |
|
| 1524 |
if not name or not price_str:
|
| 1525 |
flash("Название и цена товара обязательны.", 'error')
|
|
|
|
| 1578 |
new_product = {
|
| 1579 |
'name': name, 'price': price, 'description': description,
|
| 1580 |
'category': category if category in categories else 'Без категории',
|
| 1581 |
+
'photos': photos_list, 'colors': colors,
|
| 1582 |
+
'in_stock': in_stock, 'is_top': is_top
|
| 1583 |
}
|
| 1584 |
products.append(new_product)
|
| 1585 |
+
|
| 1586 |
save_data(data)
|
| 1587 |
logging.info(f"Товар '{name}' добавлен.")
|
| 1588 |
flash(f"Товар '{name}' успешно добавлен.", 'success')
|
|
|
|
| 1595 |
|
| 1596 |
try:
|
| 1597 |
index = int(index_str)
|
| 1598 |
+
|
| 1599 |
+
# We need to find the *original* index in the unsorted/unfiltered list
|
| 1600 |
+
original_product_list = data.get('products', [])
|
| 1601 |
+
if not (0 <= index < len(original_product_list)): raise IndexError("Индекс вне диапазона")
|
| 1602 |
+
product_to_edit = original_product_list[index]
|
| 1603 |
original_name = product_to_edit.get('name', 'N/A')
|
| 1604 |
+
|
| 1605 |
except (ValueError, IndexError):
|
| 1606 |
flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
|
| 1607 |
return redirect(url_for('admin'))
|
|
|
|
| 1612 |
category = request.form.get('category')
|
| 1613 |
product_to_edit['category'] = category if category in categories else 'Без категории'
|
| 1614 |
product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1615 |
+
product_to_edit['in_stock'] = 'in_stock' in request.form
|
| 1616 |
+
product_to_edit['is_top'] = 'is_top' in request.form
|
| 1617 |
+
|
| 1618 |
|
| 1619 |
try:
|
| 1620 |
price = round(float(price_str), 2)
|
|
|
|
| 1683 |
elif uploaded_count == 0 and any(f.filename for f in photos_files):
|
| 1684 |
flash("Не удалось загрузить новые фотографии.", "error")
|
| 1685 |
|
| 1686 |
+
|
| 1687 |
save_data(data)
|
| 1688 |
logging.info(f"Товар '{original_name}' (индекс {index}) обновлен на '{product_to_edit['name']}'.")
|
| 1689 |
flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
|
|
|
|
| 1696 |
return redirect(url_for('admin'))
|
| 1697 |
try:
|
| 1698 |
index = int(index_str)
|
| 1699 |
+
original_product_list = data.get('products', [])
|
| 1700 |
+
if not (0 <= index < len(original_product_list)): raise IndexError("Индекс вне диапазона")
|
| 1701 |
+
deleted_product = original_product_list.pop(index)
|
| 1702 |
product_name = deleted_product.get('name', 'N/A')
|
| 1703 |
|
| 1704 |
photos_to_delete = deleted_product.get('photos', [])
|
|
|
|
| 1730 |
password = request.form.get('password', '').strip()
|
| 1731 |
first_name = request.form.get('first_name', '').strip()
|
| 1732 |
last_name = request.form.get('last_name', '').strip()
|
| 1733 |
+
phone = request.form.get('phone', '').strip()
|
| 1734 |
country = request.form.get('country', '').strip()
|
| 1735 |
city = request.form.get('city', '').strip()
|
| 1736 |
|
|
|
|
| 1744 |
users[login] = {
|
| 1745 |
'password': password,
|
| 1746 |
'first_name': first_name, 'last_name': last_name,
|
| 1747 |
+
'phone': phone,
|
| 1748 |
'country': country, 'city': city
|
| 1749 |
}
|
| 1750 |
save_users(users)
|
|
|
|
| 1773 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1774 |
return redirect(url_for('admin'))
|
| 1775 |
|
| 1776 |
+
# Pass the original, unsorted product list to the admin template with original indices
|
| 1777 |
+
original_products_with_indices = list(enumerate(data.get('products', [])))
|
| 1778 |
+
# Sort the indexed list for display purposes if needed, but keep original index
|
| 1779 |
+
display_products = sorted(original_products_with_indices, key=lambda item: item[1].get('name', '').lower())
|
| 1780 |
+
# Reconstruct list of products in display order for the template
|
| 1781 |
+
# Need to pass the original index within the product data or handle it carefully
|
| 1782 |
+
# Let's pass the original product list directly for simplicity in the template loops
|
| 1783 |
+
original_product_list = data.get('products', [])
|
| 1784 |
+
|
| 1785 |
categories.sort()
|
| 1786 |
sorted_users = dict(sorted(users.items()))
|
| 1787 |
|
| 1788 |
return render_template_string(
|
| 1789 |
ADMIN_TEMPLATE,
|
| 1790 |
+
products=original_product_list, # Pass the original list to preserve indices
|
| 1791 |
categories=categories,
|
| 1792 |
users=sorted_users,
|
| 1793 |
repo_id=REPO_ID,
|
|
|
|
| 1830 |
|
| 1831 |
port = int(os.environ.get('PORT', 7860))
|
| 1832 |
logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
|
| 1833 |
+
app.run(debug=False, host='0.0.0.0', port=port)
|
| 1834 |
+
|