Admin / app.py
Kgshop's picture
Update app.py
65312fd verified
Raw
History Blame Contribute Delete
101 kB
from flask import Flask, render_template_string, request, redirect, url_for, flash, send_file, jsonify
import json
import os
import logging
import threading
import time
from datetime import datetime
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError
from werkzeug.utils import secure_filename
import uuid
import html
import random
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "zzirix_secret_key_for_cart")
DATA_FILE = 'data_zzirix.json'
REPO_ID = "Kgshop/clients"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE
LOGO_URL = "https://huggingface.co/spaces/Kgshop/Zzirixadm/resolve/main/Picsart_25-03-20_15-38-36-600.jpg"
logging.basicConfig(level=logging.INFO)
def initialize_data_structure(data):
if not isinstance(data, dict):
data = {'categories': [], 'products': [], 'orders': {}}
data.setdefault('categories', [])
data.setdefault('products', [])
data.setdefault('orders', {})
if any(p.get('category') == 'Без категории' for p in data['products']) and 'Без категории' not in data['categories']:
data['categories'].append('Без категории')
for product in data['products']:
if 'id' not in product:
product['id'] = str(uuid.uuid4())
product.setdefault('name', 'Без названия')
product.setdefault('description', '')
product.setdefault('category', 'Без категории')
product.setdefault('price', 0.0)
product.setdefault('colors', [])
product.setdefault('models', [])
product.setdefault('photos', [])
product.setdefault('in_stock', True)
product.setdefault('is_top', False)
if not product['photos'] and 'media' in product and product['media']:
product['photos'] = [m['filename'] for m in product['media'] if m['type'] == 'photo']
if 'media' in product:
del product['media']
data['categories'] = sorted(list(set(data['categories'])), key=lambda x: (x != 'Без категории', x))
return data
def load_data():
try:
if not os.path.exists(DATA_FILE) or os.path.getsize(DATA_FILE) == 0:
download_db_from_hf()
if os.path.exists(DATA_FILE) and os.path.getsize(DATA_FILE) > 0:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
else:
data = {'products': [], 'categories': [], 'orders': {}}
return initialize_data_structure(data)
except Exception as e:
return initialize_data_structure({})
def save_data(data):
try:
temp_file = DATA_FILE + '.tmp'
with open(temp_file, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
os.replace(temp_file, DATA_FILE)
upload_db_to_hf()
except Exception as e:
if os.path.exists(temp_file):
os.remove(temp_file)
raise
def upload_db_to_hf():
if not HF_TOKEN_WRITE:
return
try:
api = HfApi()
api.upload_file(
path_or_fileobj=DATA_FILE,
path_in_repo=DATA_FILE,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
except Exception as e:
pass
def download_db_from_hf():
if not HF_TOKEN_READ:
if not os.path.exists(DATA_FILE):
with open(DATA_FILE, 'w', encoding='utf-8') as f:
json.dump(initialize_data_structure({}), f)
return
try:
hf_hub_download(
repo_id=REPO_ID,
filename=DATA_FILE,
repo_type="dataset",
token=HF_TOKEN_READ,
local_dir=".",
local_dir_use_symlinks=False,
force_download=True
)
except Exception as e:
if not os.path.exists(DATA_FILE):
with open(DATA_FILE, 'w', encoding='utf-8') as f:
json.dump(initialize_data_structure({}), f)
def periodic_backup():
while True:
time.sleep(3600)
try:
data = load_data()
save_data(data)
except Exception as e:
pass
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in {'png', 'jpg', 'jpeg', 'gif', 'webp', 'mp4', 'mov', 'webm'}
BASE_STYLE = '''
:root {
--primary-color: #3B82F6;
--primary-dark-color: #2563eb;
--accent-color: #10b981;
--accent-dark-color: #059669;
--danger-color: #ef4444;
--danger-dark-color: #dc2626;
--background-light: linear-gradient(135deg, #f0f2f5, #e0e5ec);
--background-dark: linear-gradient(135deg, #1f2937, #374151);
--card-background-light: #ffffff;
--card-background-dark: #2d3748;
--text-color-light: #2d3748;
--text-color-dark: #e2e8f0;
--secondary-text-color-light: #718096;
--secondary-text-color-dark: #a0aec0;
--border-color-light: #e2e8f0;
--border-color-dark: #4a5568;
--shadow-light: 0 8px 25px rgba(0, 0, 0, 0.1);
--shadow-hover-light: 0 12px 35px rgba(0, 0, 0, 0.18);
--shadow-dark: 0 8px 25px rgba(0, 0, 0, 0.35);
--shadow-hover-dark: 0 12px 35px rgba(0, 0, 0, 0.5);
--border-radius-large: 22px;
--border-radius-medium: 12px;
--border-radius-small: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', sans-serif;
background: var(--background-light);
color: var(--text-color-light);
line-height: 1.6;
transition: background 0.3s, color 0.3s;
min-height: 100vh;
display: flex;
flex-direction: column;
}
body.dark-mode {
background: var(--background-dark);
color: var(--text-color-dark);
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 30px;
flex-grow: 1;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 30px;
border-bottom: none;
margin-bottom: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
border-radius: var(--border-radius-large);
background: var(--card-background-light);
}
body.dark-mode .header {
border-bottom-color: var(--border-color-dark);
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
background: var(--card-background-dark);
}
.header-info {
display: flex;
align-items: center;
}
.header-logo {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
transition: transform 0.3s ease, box-shadow 0.3s ease;
margin-right: 15px;
}
.header-logo:hover {
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
}
.header h1 {
font-size: 2rem;
font-weight: 800;
color: var(--primary-color);
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.theme-toggle {
background: none;
border: none;
font-size: 1.8rem;
cursor: pointer;
color: var(--secondary-text-color-light);
transition: color 0.3s ease, transform 0.2s ease;
padding: 5px;
}
body.dark-mode .theme-toggle {
color: var(--secondary-text-color-dark);
}
.theme-toggle:hover {
color: var(--accent-color);
transform: rotate(30deg);
}
.flash {
padding: 18px;
margin-bottom: 25px;
border-radius: var(--border-radius-medium);
font-weight: 500;
border: 1px solid transparent;
font-size: 1.1rem;
}
.flash.success {
background-color: var(--accent-color);
color: white;
border-color: var(--accent-dark-color);
}
.flash.error {
background-color: var(--danger-color);
color: white;
border-color: var(--danger-dark-color);
}
.filters-container {
margin: 30px 0;
display: flex;
flex-wrap: wrap;
gap: 15px;
justify-content: center;
}
.search-container {
margin: 25px 0;
text-align: center;
}
#search-input {
width: 90%;
max-width: 600px;
padding: 14px 22px;
font-size: 1rem;
border: none;
border-radius: var(--border-radius-medium);
outline: none;
box-shadow: var(--shadow-light);
transition: all 0.3s ease;
background-color: var(--card-background-light);
color: var(--text-color-light);
}
body.dark-mode #search-input {
border-color: var(--border-color-dark);
background-color: var(--card-background-dark);
color: var(--text-color-dark);
box-shadow: var(--shadow-dark);
}
#search-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 10px rgba(59, 130, 246, 0.4);
}
.category-filter {
padding: 12px 25px;
border: none;
border-radius: var(--border-radius-medium);
background-color: var(--card-background-light);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 1rem;
font-weight: 500;
color: var(--text-color-light);
box-shadow: var(--shadow-light);
}
body.dark-mode .category-filter {
border-color: var(--border-color-dark);
background-color: var(--card-background-dark);
color: var(--text-color-dark);
box-shadow: var(--shadow-dark);
}
.category-filter.active, .category-filter:hover {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
box-shadow: var(--shadow-hover-light);
}
body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
box-shadow: var(--shadow-hover-dark);
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 25px;
padding: 15px;
}
.product {
background: var(--card-background-light);
border-radius: var(--border-radius-large);
padding: 20px;
box-shadow: var(--shadow-light);
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;
}
body.dark-mode .product {
background: var(--card-background-dark);
box-shadow: var(--shadow-dark);
}
.product:hover {
transform: translateY(-10px) scale(1.03);
box-shadow: var(--shadow-hover-light);
}
body.dark-mode .product:hover {
box-shadow: var(--shadow-hover-dark);
}
.product-image {
width: 100%;
aspect-ratio: 1;
background-color: #f7f7f7;
border-radius: var(--border-radius-medium);
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 15px;
cursor: pointer;
}
body.dark-mode .product-image {
background-color: #343e4d;
}
.product-image img, .product-image video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: transform 0.3s ease;
border-radius: var(--border-radius-medium);
}
.product-image img:hover, .product-image video:hover {
transform: scale(1.05);
}
.product h2 {
font-size: 1.2rem;
font-weight: 700;
margin: 8px 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-color-light);
}
body.dark-mode .product h2 {
color: var(--text-color-dark);
}
.product-price {
font-size: 1.3rem;
color: var(--danger-color);
font-weight: 700;
text-align: center;
margin: 8px 0 12px;
}
.product-description {
font-size: 0.9rem;
color: var(--secondary-text-color-light);
text-align: center;
margin-bottom: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.dark-mode .product-description {
color: var(--secondary-text-color-dark);
}
.product-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: auto;
}
.product-button {
display: block;
width: 100%;
padding: 12px;
border: none;
border-radius: var(--border-radius-medium);
background-color: var(--primary-color);
color: white;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
text-align: center;
text-decoration: none;
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
}
.product-button:hover {
background-color: var(--primary-dark-color);
box-shadow: 0 6px 15px rgba(59, 130, 246, 0.4);
transform: translateY(-2px);
}
.add-to-cart {
background-color: var(--accent-color);
box-shadow: 0 3px 10px rgba(16, 185, 129, 0.2);
}
.add-to-cart:hover {
background-color: var(--accent-dark-color);
box-shadow: 0 6px 15px rgba(16, 185, 129, 0.4);
}
#cart-button {
position: fixed;
bottom: 30px;
right: 30px;
background-color: var(--danger-color);
color: white;
border: none;
border-radius: 50%;
width: 60px;
height: 60px;
font-size: 1.5rem;
cursor: pointer;
box-shadow: 0 8px 25px rgba(239, 68, 68, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
#cart-button:hover {
background-color: var(--danger-dark-color);
transform: translateY(-5px) scale(1.08);
box-shadow: 0 12px 35px rgba(239, 68, 68, 0.5);
}
.cart-count {
position: absolute;
top: -5px;
right: -5px;
background-color: var(--accent-color);
color: white;
border-radius: 50%;
padding: 4px 8px;
font-size: 0.8rem;
min-width: 22px;
text-align: center;
font-weight: 600;
}
.modal {
display: none;
position: fixed;
z-index: 1001;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.6);
backdrop-filter: blur(8px);
overflow-y: auto;
padding: 20px;
}
.modal-content {
background: var(--card-background-light);
margin: 50px auto;
padding: 40px;
border-radius: var(--border-radius-large);
width: 95%;
max-width: 750px;
box-shadow: var(--shadow-hover-light);
animation: fadeInScale 0.3s ease-out;
max-height: 90vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
position: relative;
}
body.dark-mode .modal-content {
background: var(--card-background-dark);
color: var(--text-color-dark);
box-shadow: var(--shadow-hover-dark);
}
@keyframes fadeInScale {
from { opacity: 0; transform: translateY(-30px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.close {
position: absolute;
top: 20px;
right: 25px;
font-size: 2rem;
color: var(--secondary-text-color-light);
cursor: pointer;
transition: color 0.3s, transform 0.2s;
}
.close:hover {
color: var(--danger-color);
transform: rotate(90deg);
}
body.dark-mode .close {
color: var(--secondary-text-color-dark);
}
body.dark-mode .close:hover {
color: var(--danger-color);
}
.modal h2 {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 25px;
text-align: center;
}
.cart-item {
display: flex;
align-items: center;
padding: 18px 0;
border-bottom: 1px solid var(--border-color-light);
}
body.dark-mode .cart-item {
border-bottom-color: var(--border-color-dark);
}
.cart-item:last-child {
border-bottom: none;
}
.cart-item img {
width: 70px;
height: 70px;
object-fit: contain;
border-radius: var(--border-radius-small);
margin-right: 20px;
background-color: #f7f7f7;
}
body.dark-mode .cart-item img {
background-color: #343e4d;
}
.cart-item-details {
flex-grow: 1;
}
.cart-item-details strong {
font-size: 1.2rem;
font-weight: 600;
}
.cart-item-details p {
font-size: 0.95rem;
color: var(--secondary-text-color-light);
}
body.dark-mode .cart-item-details p {
color: var(--secondary-text-color-dark);
}
.cart-item-total {
font-size: 1.1rem;
font-weight: 700;
color: var(--danger-color);
}
.quantity-input, .color-select, .model-select {
width: 100%;
padding: 12px;
border: 1px solid var(--border-color-light);
border-radius: var(--border-radius-medium);
font-size: 1rem;
margin: 10px 0;
background-color: var(--card-background-light);
color: var(--text-color-light);
box-shadow: inset 0 1px 3px rgba(0,0,0,0.05);
}
body.dark-mode .quantity-input, body.dark-mode .color-select, body.dark-mode .model-select {
border-color: var(--border-color-dark);
background-color: var(--card-background-dark);
color: var(--text-color-dark);
box-shadow: inset 0 1px 3px rgba(0,0,0,0.2);
}
.qty-controls {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
}
.qty-btn {
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius-small);
width: 40px;
height: 40px;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.qty-btn:hover {
background-color: var(--primary-dark-color);
}
.qty-controls input {
margin: 0;
text-align: center;
width: calc(100% - 100px);
}
.modal-buttons {
margin-top: 30px;
display: flex;
justify-content: flex-end;
gap: 15px;
}
.modal-buttons .product-button {
width: auto;
padding: 12px 25px;
}
.clear-cart {
background-color: var(--danger-color);
}
.clear-cart:hover {
background-color: var(--danger-dark-color);
box-shadow: 0 6px 15px rgba(239, 68, 68, 0.4);
}
.order-button {
background-color: var(--accent-color);
}
.order-button:hover {
background-color: var(--accent-dark-color);
box-shadow: 0 6px 15px rgba(16, 185, 129, 0.4);
}
.swiper-container {
max-width: 500px;
margin: 0 auto 30px;
border-radius: var(--border-radius-large);
overflow: hidden;
box-shadow: var(--shadow-light);
}
body.dark-mode .swiper-container {
box-shadow: var(--shadow-dark);
}
.swiper-slide {
background-color: #f7f7f7;
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
body.dark-mode .swiper-slide {
background-color: #343e4d;
}
.swiper-slide img, .swiper-slide video {
max-width: 100%;
max-height: 350px;
object-fit: contain;
}
.swiper-button-next, .swiper-button-prev {
color: var(--primary-color) !important;
background-color: rgba(255,255,255,0.9);
border-radius: 50%;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s;
font-size: 1.8rem;
}
.swiper-button-next:hover, .swiper-button-prev:hover {
background-color: #fff;
}
body.dark-mode .swiper-button-next, body.dark-mode .swiper-button-prev {
background-color: rgba(45, 55, 72, 0.9);
}
body.dark-mode .swiper-button-next:hover, body.dark-mode .swiper-button-prev:hover {
background-color: #2d3748;
}
.swiper-pagination-bullet {
background-color: var(--primary-color) !important;
}
.product-detail-modal-inner {
background: transparent;
padding: 0;
margin: 0;
box-shadow: none;
position: static;
}
body.dark-mode .product-detail-modal-inner {
background: transparent;
box-shadow: none;
}
.product-detail-modal-inner h2 {
font-size: 2.2rem;
font-weight: 700;
margin-bottom: 25px;
text-align: center;
color: var(--text-color-light);
}
body.dark-mode .product-detail-modal-inner h2 {
color: var(--text-color-dark);
}
.product-detail-modal-inner p {
margin-bottom: 10px;
font-size: 1rem;
}
.product-detail-modal-inner p strong {
color: var(--text-color-light);
}
body.dark-mode .product-detail-modal-inner p strong {
color: var(--text-color-dark);
}
.product-detail-modal-inner .price {
font-size: 1.8rem;
color: var(--danger-color);
font-weight: 700;
margin-bottom: 20px;
text-align: center;
}
.product-detail-modal-inner .description {
margin-bottom: 20px;
white-space: pre-wrap;
}
.product-detail-modal-actions {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 30px;
}
.product-detail-modal-actions .product-button {
width: auto;
padding: 12px 25px;
}
.admin-table { width: 100%; border-collapse: collapse; margin-top: 20px; }
.admin-table th, .admin-table td { padding: 12px; border: 1px solid var(--border-color-light); text-align: left; vertical-align: middle; }
body.dark-mode .admin-table th, body.dark-mode .admin-table td { border-color: var(--border-color-dark); }
.admin-table th { background-color: var(--card-background-light); font-weight: 600; }
body.dark-mode .admin-table th { background-color: var(--card-background-dark); }
.admin-table-responsive { overflow-x: auto; margin-bottom: 20px; }
@media (max-width: 768px) {
.container {
padding: 15px;
}
.header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.header-actions {
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
.header h1 {
font-size: 1.6rem;
}
.category-filter {
font-size: 0.85rem;
padding: 8px 15px;
}
.products-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 18px;
}
.product h2 {
font-size: 1.05rem;
}
.product-price {
font-size: 1.15rem;
}
.product-description {
font-size: 0.8rem;
}
.product-button {
font-size: 0.9rem;
padding: 10px;
}
#cart-button {
width: 50px;
height: 50px;
font-size: 1.2rem;
bottom: 20px;
right: 20px;
}
.modal-content {
margin: 20px auto;
padding: 25px;
max-height: 95vh;
}
.close {
font-size: 1.6rem;
top: 15px;
right: 18px;
}
.modal h2 {
font-size: 1.6rem;
}
.cart-item img {
width: 55px;
height: 55px;
}
.product-detail-modal-inner h2 {
font-size: 2rem;
}
.product-detail-modal-inner .price {
font-size: 1.6rem;
}
.product-detail-modal-actions {
flex-direction: column;
gap: 10px;
}
}
'''
JS_FUNCTIONS = '''
const products = {{ products|tojson }};
let selectedProductId = null;
let detailSwiperInstance = null;
function toggleTheme() {
document.body.classList.toggle('dark-mode');
const icon = document.querySelector('.theme-toggle i');
icon.classList.toggle('fa-moon');
icon.classList.toggle('fa-sun');
localStorage.setItem('theme', document.body.classList.contains('dark-mode') ? 'dark' : 'light');
}
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark-mode');
const icon = document.querySelector('.theme-toggle i');
if (icon) icon.classList.replace('fa-moon', 'fa-sun');
} else {
const icon = document.querySelector('.theme-toggle i');
if (icon) icon.classList.replace('fa-sun', 'fa-moon');
}
function closeModal(modalId) {
document.getElementById(modalId).style.display = "none";
if (modalId === 'productDetailModal' && detailSwiperInstance) {
detailSwiperInstance.destroy(true, true);
detailSwiperInstance = null;
}
}
function openQuantityModal(productId) {
selectedProductId = productId;
const product = products.find(p => p.id === productId);
if (!product) {
alert('Ошибка: Товар не найден.');
return;
}
const colorSelect = document.getElementById('colorSelect');
colorSelect.innerHTML = '';
if (product.colors && product.colors.length > 0) {
product.colors.forEach(color => {
const option = document.createElement('option');
option.value = color;
option.text = color;
colorSelect.appendChild(option);
});
} else {
const option = document.createElement('option');
option.value = 'Не указан';
option.text = 'Цвет не указан';
colorSelect.appendChild(option);
}
const modelSelect = document.getElementById('modelSelect');
modelSelect.innerHTML = '';
if (product.models && product.models.length > 0) {
product.models.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.text = model;
modelSelect.appendChild(option);
});
} else {
const option = document.createElement('option');
option.value = 'Не указана';
option.text = 'Модель не указана';
modelSelect.appendChild(option);
}
document.getElementById('quantityModal').style.display = 'block';
document.getElementById('quantityInput').value = 1;
}
function changeQty(delta) {
let input = document.getElementById('quantityInput');
let current = parseInt(input.value) || 1;
if(current + delta >= 1) {
input.value = current + delta;
}
}
function confirmAddToCart() {
if (selectedProductId === null) return;
const quantityInput = document.getElementById('quantityInput');
const quantity = parseInt(quantityInput.value) || 1;
const color = document.getElementById('colorSelect').value;
const model = document.getElementById('modelSelect').value;
if (quantity <= 0) {
alert("Укажите количество больше 0.");
quantityInput.focus();
return;
}
let cart = JSON.parse(localStorage.getItem('cart') || '[]');
const product = products.find(p => p.id === selectedProductId);
const cartItemId = `${product.id}-${color}-${model}`;
const existingItem = cart.find(item => item.cartId === cartItemId);
if (existingItem) {
existingItem.quantity += quantity;
} else {
cart.push({
cartId: cartItemId,
productId: product.id,
name: product.name,
price: product.price,
photo: product.photos && product.photos.length > 0 ? product.photos[0] : '',
quantity: quantity,
color: color,
model: model,
discount_percent: 0.0
});
}
localStorage.setItem('cart', JSON.stringify(cart));
closeModal('quantityModal');
updateCartButton();
alert(`"${product.name}" добавлен в корзину.`);
}
function updateCartButton() {
const cart = JSON.parse(localStorage.getItem('cart') || '[]');
const cartCount = cart.reduce((sum, item) => sum + item.quantity, 0);
document.getElementById('cart-count').textContent = cartCount;
document.getElementById('cart-button').style.display = cartCount > 0 ? 'flex' : 'none';
}
function openCartModal() {
const cart = JSON.parse(localStorage.getItem('cart') || '[]');
const cartContent = document.getElementById('cartContent');
let total = 0;
cartContent.innerHTML = cart.length === 0 ? '<p style="text-align: center; color: var(--secondary-text-color-light);">Корзина пуста</p>' : cart.map((item, index) => {
const itemTotal = item.price * item.quantity;
total += itemTotal;
const photoSrc = item.photo ? `https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/${item.photo}` : 'https://via.placeholder.com/60x60?text=No+Image';
return `
<div class="cart-item">
<img src="${photoSrc}" alt="${item.name}">
<div class="cart-item-details">
<strong>${item.name}</strong>
<p>${item.price.toFixed(2)} с × ${item.quantity} (Цвет: ${item.color}, Модель: ${item.model})</p>
</div>
<span class="cart-item-total">${itemTotal.toFixed(2)} с</span>
</div>
`;
}).join('');
document.getElementById('cartTotal').textContent = total.toFixed(2);
document.getElementById('cartModal').style.display = 'block';
}
async function checkoutOrder() {
const cart = JSON.parse(localStorage.getItem('cart') || '[]');
if (cart.length === 0) {
alert("Корзина пуста!");
return;
}
const cName = document.getElementById('customerName').value.trim();
const cPhone = document.getElementById('customerPhone').value.trim();
if(!cName || !cPhone){
alert("Пожалуйста, заполните Имя и Номер телефона!");
return;
}
let total = 0;
cart.forEach(item => total += item.price * item.quantity);
document.getElementById('checkoutBtn').disabled = true;
document.getElementById('checkoutBtn').textContent = "Оформление...";
try {
let response = await fetch('/checkout', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ cart: cart, name: cName, phone: cPhone, total: total })
});
let result = await response.json();
if(result.success) {
clearCart(false);
window.location.href = "/receipt/" + result.order_id;
} else {
alert("Ошибка при оформлении заказа.");
document.getElementById('checkoutBtn').disabled = false;
document.getElementById('checkoutBtn').textContent = "Заказать";
}
} catch (e) {
alert("Ошибка соединения.");
document.getElementById('checkoutBtn').disabled = false;
document.getElementById('checkoutBtn').textContent = "Заказать";
}
}
function clearCart(reopen = true) {
localStorage.removeItem('cart');
if(reopen) openCartModal();
updateCartButton();
}
window.onclick = function(event) {
if (event.target.classList.contains('modal')) {
event.target.style.display = "none";
if (detailSwiperInstance) {
detailSwiperInstance.destroy(true, true);
detailSwiperInstance = null;
}
}
}
function openProductDetailModal(productId) {
selectedProductId = productId;
const product = products.find(p => p.id === productId);
if (!product) {
alert('Ошибка: Товар не найден.');
return;
}
document.getElementById('detailProductName').textContent = product.name;
document.getElementById('detailProductCategory').textContent = product.category || 'Без категории';
document.getElementById('detailProductPrice').textContent = `${product.price.toFixed(2)} с`;
document.getElementById('detailProductDescription').textContent = product.description;
document.getElementById('detailProductColors').textContent = product.colors && product.colors.length > 0 ? product.colors.join(', ') : 'Нет цветов';
document.getElementById('detailProductModels').textContent = product.models && product.models.length > 0 ? product.models.join(', ') : 'Нет моделей';
document.getElementById('detailAddToCartButton').onclick = () => openQuantityModal(product.id);
const mediaContainer = document.getElementById('detailProductMedia');
mediaContainer.innerHTML = '';
if (product.photos && product.photos.length > 0) {
product.photos.forEach(filename => {
const slide = document.createElement('div');
slide.className = 'swiper-slide swiper-zoom-container';
slide.style = "display: flex; justify-content: center; align-items: center; border-radius: 10px;";
const mediaUrl = `https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/${filename}`;
if (filename.endsWith(('.mp4', '.mov', '.webm'))) {
slide.innerHTML = `<video controls preload="metadata" style="max-width: 100%; max-height: 350px; object-fit: contain;"><source src="${mediaUrl}" type="video/mp4"></video>`;
} else {
slide.innerHTML = `<img src="${mediaUrl}" alt="${product.name}" style="max-width: 100%; max-height: 350px; object-fit: contain;">`;
}
mediaContainer.appendChild(slide);
});
} else {
const slide = document.createElement('div');
slide.className = 'swiper-slide swiper-zoom-container';
slide.style = "display: flex; justify-content: center; align-items: center; border-radius: 10px;";
slide.innerHTML = `<img src="https://via.placeholder.com/300x300?text=No+Image" alt="No Image">`;
mediaContainer.appendChild(slide);
}
document.getElementById('productDetailModal').style.display = 'block';
if (detailSwiperInstance) {
detailSwiperInstance.destroy(true, true);
}
detailSwiperInstance = new Swiper('#productDetailModal .swiper-container', {
slidesPerView: 1,
spaceBetween: 20,
loop: true,
grabCursor: true,
pagination: { el: '#productDetailModal .swiper-pagination', clickable: true },
navigation: { nextEl: '#productDetailModal .swiper-button-next', prevEl: '#productDetailModal .swiper-button-prev' },
zoom: { maxRatio: 3 },
on: {
init: function () {
this.slides.forEach(slide => {
const img = slide.querySelector('img');
if (img && !img.complete) {
img.onload = () => detailSwiperInstance.update();
}
const video = slide.querySelector('video');
if (video) {
video.onloadedmetadata = () => detailSwiperInstance.update();
}
});
},
resize: function() {
this.update();
}
}
});
detailSwiperInstance.update();
}
updateCartButton();
'''
@app.route('/')
def catalog():
data = load_data()
products = data.get('products', [])
categories = data.get('categories', [])
products.sort(key=lambda p: p['name'].lower())
return render_template_string('''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZZIRIX - сотовые аксессуары оптом</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
<style>''' + BASE_STYLE + '''</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-info">
<img src="''' + LOGO_URL + '''" alt="Logo" class="header-logo">
<h1>Каталог ZZIRIX</h1>
</div>
<div class="header-actions">
<button class="theme-toggle" onclick="toggleTheme()">
<i class="fas fa-moon"></i>
</button>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="filters-container">
<button class="category-filter active" data-category="all">Все категории</button>
{% for category in categories %}
<button class="category-filter" data-category="{{ category | lower }}">{{ category }}</button>
{% endfor %}
</div>
<div class="search-container">
<input type="text" id="search-input" placeholder="Поиск товаров...">
</div>
<div class="products-grid" id="products-grid">
{% for product in products %}
<div class="product"
data-name="{{ product['name']|lower }}"
data-description="{{ product['description']|lower }}"
data-category="{{ product.get('category', 'Без категории')|lower }}">
<a href="javascript:void(0)" onclick="openProductDetailModal('{{ product.id }}')" class="product-image">
{% if product.get('photos') and product['photos']|length > 0 %}
{% set filename = product['photos'][0] %}
{% if filename.endswith(('.mp4', '.mov', '.webm')) %}
<video preload="metadata" muted loop autoplay playsinline><source src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ filename }}" type="video/mp4"></video>
{% else %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ filename }}" alt="{{ product['name'] }}" loading="lazy">
{% endif %}
{% else %}
<img src="https://via.placeholder.com/200x200?text=No+Image" alt="Нет изображения" loading="lazy">
{% endif %}
</a>
<h2>{{ product['name'] }}</h2>
<div class="product-price">{{ "%.2f"|format(product['price']) }} с</div>
<p class="product-description">{{ product['description'][:50] }}{% if product['description']|length > 50 %}...{% endif %}</p>
<div class="product-actions">
<button class="product-button" onclick="openProductDetailModal('{{ product.id }}')">Подробнее</button>
<button class="product-button add-to-cart" onclick="openQuantityModal('{{ product.id }}')">В корзину</button>
</div>
</div>
{% else %}
<p style="grid-column: 1 / -1; text-align: center; padding: 40px 0;">Товаров пока нет. Загляните позже!</p>
{% endfor %}
</div>
</div>
<div id="quantityModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('quantityModal')">×</span>
<h2>Укажите количество, цвет и модель</h2>
<div class="qty-controls">
<button type="button" class="qty-btn" onclick="changeQty(-1)">-</button>
<input type="number" id="quantityInput" class="quantity-input" min="1" value="1">
<button type="button" class="qty-btn" onclick="changeQty(1)">+</button>
</div>
<select id="colorSelect" class="color-select"></select>
<select id="modelSelect" class="model-select"></select>
<div class="modal-buttons">
<button class="product-button order-button" onclick="confirmAddToCart()">Добавить</button>
</div>
</div>
</div>
<div id="cartModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('cartModal')">×</span>
<h2>Корзина</h2>
<div id="cartContent"></div>
<div style="margin-top: 20px;">
<input type="text" id="customerName" placeholder="Ваше Имя" required class="quantity-input">
<input type="text" id="customerPhone" placeholder="Ваш Номер телефона" required class="quantity-input">
</div>
<div style="margin-top: 20px; text-align: right;">
<strong style="font-size: 1.2rem;">Итого: <span id="cartTotal">0</span> с</strong>
<div class="modal-buttons">
<button class="product-button clear-cart" onclick="clearCart()">Очистить</button>
<button class="product-button order-button" id="checkoutBtn" onclick="checkoutOrder()">Заказать</button>
</div>
</div>
</div>
</div>
<div id="productDetailModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('productDetailModal')">×</span>
<div class="product-detail-modal-inner">
<h2 id="detailProductName"></h2>
<div class="swiper-container" style="max-width: 500px; margin: 0 auto 30px;">
<div class="swiper-wrapper" id="detailProductMedia"></div>
<div class="swiper-pagination"></div>
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
</div>
<p><strong>Категория:</strong> <span id="detailProductCategory" style="color: var(--primary-color);"></span></p>
<p class="price" id="detailProductPrice"></p>
<p class="description"><strong>Описание:</strong> <span id="detailProductDescription"></span></p>
<p><strong>Доступные цвета:</strong> <span id="detailProductColors" style="color: var(--secondary-text-color-light);"></span></p>
<p><strong>Доступные модели:</strong> <span id="detailProductModels" style="color: var(--secondary-text-color-light);"></span></p>
<div class="product-detail-modal-actions">
<button class="product-button add-to-cart" id="detailAddToCartButton">В корзину</button>
</div>
</div>
</div>
</div>
<button id="cart-button" onclick="openCartModal()">
<i class="fas fa-shopping-cart"></i>
<span id="cart-count" class="cart-count">0</span>
</button>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
<script>
''' + JS_FUNCTIONS + '''
document.getElementById('search-input').addEventListener('input', filterProducts);
document.querySelectorAll('.filters-container .category-filter').forEach(filter => {
filter.addEventListener('click', function() {
document.querySelectorAll('.filters-container .category-filter').forEach(f => f.classList.remove('active'));
this.classList.add('active');
filterProducts();
});
});
function filterProducts() {
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
const activeCategory = document.querySelector('.category-filter.active').dataset.category.toLowerCase();
document.querySelectorAll('.product').forEach(product => {
const name = product.getAttribute('data-name');
const description = product.getAttribute('data-description');
const category = product.getAttribute('data-category');
const matchesSearch = (name && name.includes(searchTerm)) || (description && description.includes(searchTerm));
const matchesCategory = activeCategory === 'all' || category === activeCategory;
product.style.display = matchesSearch && matchesCategory ? 'flex' : 'none';
});
}
</script>
</body>
</html>
''',
products=products,
categories=categories,
repo_id=REPO_ID,
LOGO_URL=LOGO_URL
)
@app.route('/category/<category_name>')
def catalog_by_category(category_name):
data = load_data()
products_all = data.get('products', [])
categories = data.get('categories', [])
products = [p for p in products_all if p.get('category', '').lower() == category_name.lower()]
products.sort(key=lambda p: p['name'].lower())
return render_template_string('''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ category_name }} - ZZIRIX</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
<style>''' + BASE_STYLE + '''</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-info">
<img src="''' + LOGO_URL + '''" alt="Logo" class="header-logo">
<h1>Каталог ZZIRIX</h1>
</div>
<div class="header-actions">
<button class="theme-toggle" onclick="toggleTheme()">
<i class="fas fa-moon"></i>
</button>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="filters-container">
<button class="category-filter" data-category="all" onclick="window.location.href='{{ url_for('catalog') }}'">Все категории</button>
{% for cat in categories %}
<button class="category-filter {% if cat | lower == category_name | lower %}active{% endif %}" data-category="{{ cat | lower }}" onclick="window.location.href='{{ url_for('catalog_by_category', category_name=cat | lower) }}'">{{ cat }}</button>
{% endfor %}
</div>
<div class="search-container">
<input type="text" id="search-input" placeholder="Поиск товаров...">
</div>
<div class="products-grid" id="products-grid">
{% for product in products %}
<div class="product"
data-name="{{ product['name']|lower }}"
data-description="{{ product['description']|lower }}"
data-category="{{ product.get('category', 'Без категории')|lower }}">
<a href="javascript:void(0)" onclick="openProductDetailModal('{{ product.id }}')" class="product-image">
{% if product.get('photos') and product['photos']|length > 0 %}
{% set filename = product['photos'][0] %}
{% if filename.endswith(('.mp4', '.mov', '.webm')) %}
<video preload="metadata" muted loop autoplay playsinline><source src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ filename }}" type="video/mp4"></video>
{% else %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ filename }}" alt="{{ product['name'] }}" loading="lazy">
{% endif %}
{% else %}
<img src="https://via.placeholder.com/200x200?text=No+Image" alt="Нет изображения" loading="lazy">
{% endif %}
</a>
<h2>{{ product['name'] }}</h2>
<div class="product-price">{{ "%.2f"|format(product['price']) }} с</div>
<p class="product-description">{{ product['description'][:50] }}{% if product['description']|length > 50 %}...{% endif %}</p>
<div class="product-actions">
<button class="product-button" onclick="openProductDetailModal('{{ product.id }}')">Подробнее</button>
<button class="product-button add-to-cart" onclick="openQuantityModal('{{ product.id }}')">В корзину</button>
</div>
</div>
{% else %}
<p style="grid-column: 1 / -1; text-align: center; padding: 40px 0;">В этой категории пока нет товаров.</p>
{% endfor %}
</div>
</div>
<div id="quantityModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('quantityModal')">×</span>
<h2>Укажите количество, цвет и модель</h2>
<div class="qty-controls">
<button type="button" class="qty-btn" onclick="changeQty(-1)">-</button>
<input type="number" id="quantityInput" class="quantity-input" min="1" value="1">
<button type="button" class="qty-btn" onclick="changeQty(1)">+</button>
</div>
<select id="colorSelect" class="color-select"></select>
<select id="modelSelect" class="model-select"></select>
<div class="modal-buttons">
<button class="product-button order-button" onclick="confirmAddToCart()">Добавить</button>
</div>
</div>
</div>
<div id="cartModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('cartModal')">×</span>
<h2>Корзина</h2>
<div id="cartContent"></div>
<div style="margin-top: 20px;">
<input type="text" id="customerName" placeholder="Ваше Имя" required class="quantity-input">
<input type="text" id="customerPhone" placeholder="Ваш Номер телефона" required class="quantity-input">
</div>
<div style="margin-top: 20px; text-align: right;">
<strong style="font-size: 1.2rem;">Итого: <span id="cartTotal">0</span> с</strong>
<div class="modal-buttons">
<button class="product-button clear-cart" onclick="clearCart()">Очистить</button>
<button class="product-button order-button" id="checkoutBtn" onclick="checkoutOrder()">Заказать</button>
</div>
</div>
</div>
</div>
<div id="productDetailModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('productDetailModal')">×</span>
<div class="product-detail-modal-inner">
<h2 id="detailProductName"></h2>
<div class="swiper-container" style="max-width: 500px; margin: 0 auto 30px;">
<div class="swiper-wrapper" id="detailProductMedia"></div>
<div class="swiper-pagination"></div>
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
</div>
<p><strong>Категория:</strong> <span id="detailProductCategory" style="color: var(--primary-color);"></span></p>
<p class="price" id="detailProductPrice"></p>
<p class="description"><strong>Описание:</strong> <span id="detailProductDescription"></span></p>
<p><strong>Доступные цвета:</strong> <span id="detailProductColors" style="color: var(--secondary-text-color-light);"></span></p>
<p><strong>Доступные модели:</strong> <span id="detailProductModels" style="color: var(--secondary-text-color-light);"></span></p>
<div class="product-detail-modal-actions">
<button class="product-button add-to-cart" id="detailAddToCartButton">В корзину</button>
</div>
</div>
</div>
</div>
<button id="cart-button" onclick="openCartModal()">
<i class="fas fa-shopping-cart"></i>
<span id="cart-count" class="cart-count">0</span>
</button>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
<script>
''' + JS_FUNCTIONS + '''
document.getElementById('search-input').addEventListener('input', filterProducts);
function filterProducts() {
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
document.querySelectorAll('.product').forEach(product => {
const name = product.getAttribute('data-name');
const description = product.getAttribute('data-description');
const matchesSearch = (name && name.includes(searchTerm)) || (description && description.includes(searchTerm));
product.style.display = matchesSearch ? 'flex' : 'none';
});
}
</script>
</body>
</html>
''',
products=products,
categories=categories,
category_name=category_name,
products_all=products_all,
repo_id=REPO_ID,
LOGO_URL=LOGO_URL
)
@app.route('/checkout', methods=['POST'])
def checkout():
try:
order_data = request.json
order_id = str(uuid.uuid4())[:8].upper()
order_data['id'] = order_id
order_data['date'] = datetime.now().strftime('%d.%m.%Y %H:%M')
order_data['original_total'] = order_data['total']
order_data['final_total'] = order_data['total']
order_data['order_discount_percent'] = 0.0
for item in order_data.get('cart', []):
if 'discount_percent' not in item:
item['discount_percent'] = 0.0
data = load_data()
data['orders'][order_id] = order_data
save_data(data)
return jsonify({'success': True, 'order_id': order_id})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@app.route('/receipt/<order_id>')
def view_receipt(order_id):
data = load_data()
order = data.get('orders', {}).get(order_id)
if not order:
return "Заказ не найден", 404
return render_template_string('''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Чек №{{ order.id }}</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
body { background: #f0f2f5; font-family: 'Roboto', sans-serif; margin: 0; padding: 10px; }
.receipt-wrapper { max-width: 800px; margin: 0 auto; background: #fff; padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); color: #000; box-sizing: border-box; width: 100%; }
.r-title { text-align: center; font-size: 1.3rem; font-weight: bold; margin-bottom: 20px; }
.r-client-info { margin-bottom: 20px; font-size: 0.95rem; line-height: 1.5; }
.table-responsive { width: 100%; overflow-x: auto; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; min-width: 500px; font-size: 0.9rem; }
th, td { border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; padding: 10px 8px; text-align: center; word-break: break-word; }
th { font-weight: 600; background-color: #f9fafb; }
td:nth-child(3) { text-align: left; min-width: 150px; }
.r-summary { display: flex; justify-content: flex-end; margin-bottom: 30px; }
.r-summary table { width: 100%; max-width: 300px; min-width: unset; border: none; }
.r-summary th, .r-summary td { border: none; padding: 6px; text-align: right; background-color: transparent; }
.r-summary td:last-child { font-weight: bold; width: 40%; }
.r-footer-text { font-size: 0.85rem; color: #555; margin-bottom: 30px; text-align: center; }
.r-signatures { display: flex; justify-content: space-between; gap: 20px; font-size: 0.9rem; margin-top: 30px; flex-wrap: wrap; }
.r-sig-box { flex: 1; min-width: 120px; }
.r-line { border-bottom: 1px solid #000; margin-top: 25px; margin-bottom: 5px; }
.r-sig-desc { text-align: center; font-size: 0.75rem; color: #555; }
.btn-controls { display: flex; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 20px; }
.btn { padding: 10px 15px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 0.95rem; color: #fff; flex: 1; min-width: 120px; max-width: 200px; text-align: center; }
.btn-whatsapp { background: #25D366; }
.btn-share { background: #3B82F6; }
.btn-print { background: #6B7280; }
@media print {
body { background: #fff; padding: 0; }
.btn-controls { display: none; }
.receipt-wrapper { box-shadow: none; padding: 0; width: 100%; border-radius: 0; }
.table-responsive { overflow-x: visible; }
table { min-width: 100%; }
}
@media (max-width: 480px) {
.receipt-wrapper { padding: 15px; }
.r-title { font-size: 1.1rem; }
.btn { font-size: 0.85rem; padding: 10px; min-width: 100px; }
th, td { font-size: 0.8rem; padding: 8px 5px; }
.r-summary td:last-child { width: 50%; }
}
</style>
</head>
<body>
<div class="btn-controls">
<button class="btn btn-whatsapp" onclick="sendWhatsApp()">Отправить заказ</button>
<button class="btn btn-share" onclick="shareReceipt()">Поделиться</button>
<button class="btn btn-print" onclick="window.print()">Печать</button>
</div>
<div class="receipt-wrapper">
<div class="r-title">Товарный чек № {{ order.id }} от {{ order.date }}</div>
<div class="r-client-info">
<strong>Клиент:</strong> {{ order.name }}<br>
<strong>Телефон:</strong> {{ order.phone }}
</div>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>№</th>
<th>Код</th>
<th>Наименование</th>
<th>Кол-во</th>
<th>Ед.</th>
<th>Цена</th>
<th>Скидка %</th>
<th>Сумма</th>
</tr>
</thead>
<tbody>
{% for item in order.cart %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ item.productId[:6] }}</td>
<td>{{ item.name }} (Цвет: {{ item.color }}, Мод: {{ item.model }})</td>
<td>{{ item.quantity }}</td>
<td>шт</td>
<td>{{ "%.2f"|format(item.price) }}</td>
<td>{{ "%.2f"|format(item.get('discount_percent', 0.0)) }}</td>
{% set item_final = item.price * (1 - item.get('discount_percent', 0.0) / 100) * item.quantity %}
<td>{{ "%.2f"|format(item_final) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="r-summary">
{% set original = order.get('original_total', order.total) %}
{% set final = order.get('final_total', order.total) %}
{% set total_disc = original - final %}
<table>
<tr><td>Сумма без скидки</td><td>{{ "%.2f"|format(original) }}</td></tr>
<tr><td>Скидка{% if order.get('order_discount_percent', 0) > 0 %} ({{ "%.2f"|format(order.order_discount_percent) }}% на весь чек){% endif %}</td><td>{{ "%.2f"|format(total_disc) }}</td></tr>
<tr><td>Итого</td><td>{{ "%.2f"|format(final) }}</td></tr>
<tr><td>Наличными</td><td>{{ "%.2f"|format(final) }}</td></tr>
</table>
</div>
<div class="r-footer-text">
Всего наименований {{ order.cart|length }} на сумму {{ "%.2f"|format(final) }} с.
</div>
<div class="r-signatures">
<div class="r-sig-box">
Продавец
<div class="r-line"></div>
<div class="r-sig-desc">подпись</div>
</div>
<div class="r-sig-box">
Администратор
<div class="r-line"></div>
<div class="r-sig-desc">расшифровка</div>
</div>
</div>
</div>
<script>
function sendWhatsApp() {
const url = window.location.href;
const text = `Здравствуйте, заказ оформлен. Ссылка на чек: ${url}`;
window.open(`https://api.whatsapp.com/send?phone=996705665777&text=${encodeURIComponent(text)}`, '_blank');
}
function shareReceipt() {
if (navigator.share) {
navigator.share({
title: 'Чек заказа',
url: window.location.href
}).catch(console.error);
} else {
navigator.clipboard.writeText(window.location.href);
alert('Ссылка скопирована в буфер обмена!');
}
}
</script>
</body>
</html>
''', order=order)
@app.route('/admin', methods=['GET', 'POST'])
def admin():
data = load_data()
products = data.get('products', [])
categories = data.get('categories', [])
if request.method == 'POST':
action = request.form.get('action')
api = HfApi()
if action == 'add_category':
category_name = request.form.get('category_name', '').strip()
if category_name and category_name not in categories:
categories.append(category_name)
save_data(data)
flash('Категория успешно добавлена.', 'success')
return redirect(url_for('admin'))
flash('Ошибка: Категория уже существует или не указано название.', 'error')
return redirect(url_for('admin'))
elif action == 'delete_category':
category_name_to_delete = request.form.get('category_name')
if category_name_to_delete in categories:
categories.remove(category_name_to_delete)
for product in products:
if product.get('category') == category_name_to_delete:
product['category'] = 'Без категории'
save_data(data)
flash('Категория удалена. Связанные товары теперь в категории "Без категории".', 'success')
return redirect(url_for('admin'))
flash('Ошибка: Категория не найдена.', 'error')
return redirect(url_for('admin'))
elif action == 'add':
name = request.form.get('name', '').strip()
price = request.form.get('price', '').strip()
description = request.form.get('description', '').strip()
category = request.form.get('category', 'Без категории').strip()
photos_files = request.files.getlist('photos')
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
models = [m.strip() for m in request.form.getlist('models') if m.strip()]
if not name or not price or not description:
flash('Ошибка: Заполните все обязательные поля (Название, Цена, Описание).', 'error')
return redirect(url_for('admin'))
try:
price = float(price.replace(',', '.'))
except ValueError:
flash('Ошибка: Неверный формат цены.', 'error')
return redirect(url_for('admin'))
photos_list = []
if photos_files and any(f.filename for f in photos_files):
uploads_dir = 'uploads'
os.makedirs(uploads_dir, exist_ok=True)
for i, photo in enumerate(photos_files[:10]):
if photo and allowed_file(photo.filename):
base, extension = os.path.splitext(photo.filename)
unique_filename = secure_filename(f"{name.replace(' ','_')}_{int(time.time())}_{i}{extension}")
temp_path = os.path.join(uploads_dir, unique_filename)
try:
photo.save(temp_path)
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"photos/{unique_filename}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Добавлено фото для товара {name}"
)
photos_list.append(unique_filename)
except Exception as e:
flash(f'Ошибка при загрузке фото {photo.filename}: {e}', 'error')
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
new_product = {
'id': str(uuid.uuid4()),
'name': name,
'price': price,
'description': description,
'category': category if category in categories else 'Без категории',
'photos': photos_list,
'colors': colors,
'models': models,
'in_stock': True,
'is_top': False
}
products.append(new_product)
save_data(data)
flash('Товар успешно добавлен.', 'success')
return redirect(url_for('admin'))
elif action == 'edit':
product_id = request.form.get('product_id')
product_to_edit = next((p for p in products if p.get('id') == product_id), None)
if not product_to_edit:
flash('Ошибка: Товар не найден для редактирования.', 'error')
return redirect(url_for('admin'))
name = request.form.get('name', '').strip()
price = request.form.get('price', '').strip()
description = request.form.get('description', '').strip()
category = request.form.get('category', 'Без категории').strip()
photos_files = request.files.getlist('photos')
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
models = [m.strip() for m in request.form.getlist('models') if m.strip()]
if not name or not price or not description:
flash('Ошибка: Заполните все обязательные поля.', 'error')
return redirect(url_for('admin'))
try:
price = float(price.replace(',', '.'))
except ValueError:
flash('Ошибка: Неверный формат цены при редактировании.', 'error')
return redirect(url_for('admin'))
product_to_edit['name'] = name
product_to_edit['price'] = price
product_to_edit['description'] = description
product_to_edit['category'] = category if category in categories else 'Без категории'
product_to_edit['colors'] = colors
product_to_edit['models'] = models
if photos_files and any(f.filename for f in photos_files):
new_photos_list = []
uploads_dir = 'uploads'
os.makedirs(uploads_dir, exist_ok=True)
for old_filename in product_to_edit.get('photos', []):
try:
api.delete_file(path_in_repo=f"photos/{old_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception as e:
pass
for i, photo in enumerate(photos_files[:10]):
if photo and allowed_file(photo.filename):
base, extension = os.path.splitext(photo.filename)
unique_filename = secure_filename(f"{name.replace(' ','_')}_{int(time.time())}_{i}{extension}")
temp_path = os.path.join(uploads_dir, unique_filename)
try:
photo.save(temp_path)
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"photos/{unique_filename}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Обновлено фото для товара {name}"
)
new_photos_list.append(unique_filename)
except Exception as e:
flash(f'Ошибка при загрузке нового фото {photo.filename}: {e}', 'error')
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
product_to_edit['photos'] = new_photos_list
save_data(data)
flash('Товар успешно отредактирован.', 'success')
return redirect(url_for('admin'))
elif action == 'delete':
product_id = request.form.get('product_id')
product_to_delete = next((p for p in products if p.get('id') == product_id), None)
if product_to_delete:
for filename in product_to_delete.get('photos', []):
try:
api.delete_file(path_in_repo=f"photos/{filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
except Exception as e:
pass
data['products'] = [p for p in products if p.get('id') != product_id]
save_data(data)
flash('Товар удален.', 'success')
return redirect(url_for('admin'))
flash('Ошибка: Товар не найден для удаления.', 'error')
return redirect(url_for('admin'))
products.sort(key=lambda p: p['name'].lower())
return render_template_string('''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админ-панель ZZIRIX</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>''' + BASE_STYLE + '''
body { padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; }
.header { display: flex; align-items: center; padding: 15px 0; border-bottom: 1px solid var(--border-color-light); margin-bottom: 20px; flex-wrap: wrap; gap: 15px; }
.header-logo { width: 50px; height: 50px; border-radius: 50%; object-fit: cover; margin-right: 15px; }
h1, h2 { font-weight: 700; margin-bottom: 25px; }
form { background: var(--card-background-light); padding: 30px; border-radius: var(--border-radius-large); margin-bottom: 30px; box-shadow: var(--shadow-light); }
body.dark-mode form { background: var(--card-background-dark); }
label { font-weight: 600; margin-top: 18px; margin-bottom: 5px; display: block; }
input, textarea, select { width: 100%; padding: 12px; margin-bottom: 10px; border: 1px solid var(--border-color-light); border-radius: var(--border-radius-medium); }
body.dark-mode input, body.dark-mode textarea, body.dark-mode select { border-color: var(--border-color-dark); background-color: var(--card-background-dark); color: var(--text-color-dark); }
button { padding: 12px 22px; border: none; border-radius: var(--border-radius-medium); background-color: var(--primary-color); color: white; cursor: pointer; margin-top: 15px; }
.delete-button { background-color: var(--danger-color); margin-left: 10px; }
.product-list, .category-list { display: grid; gap: 20px; }
.product-item, .category-item { background: var(--card-background-light); padding: 20px; border-radius: var(--border-radius-large); box-shadow: var(--shadow-light); }
body.dark-mode .product-item, body.dark-mode .category-item { background: var(--card-background-dark); }
.color-input-group, .model-input-group { display: flex; gap: 10px; margin-bottom: 10px; align-items: center; }
.color-input-group input, .model-input-group input { flex-grow: 1; margin-bottom: 0; }
.remove-input-btn { background-color: #ccc; color: #555; padding: 8px 12px; margin-top: 0; }
.add-color-btn, .add-model-btn { background-color: var(--accent-color); width: auto; padding: 10px 18px; margin-bottom: 15px; }
.product-photos-preview { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; margin-bottom: 15px; }
.product-photos-preview img, .product-photos-preview video { max-width: 90px; height: auto; border-radius: var(--border-radius-small); border: 1px solid var(--border-color-light); }
#admin-search-input { width: 100%; max-width: 400px; }
.header-buttons { display: flex; gap: 10px; flex-wrap: wrap; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-info">
<img src="''' + LOGO_URL + '''" alt="Logo" class="header-logo">
<h1>Админ-панель</h1>
</div>
<div class="header-buttons">
<a href="{{ url_for('order_history') }}" style="text-decoration: none;" class="product-button">История накладных</a>
<a href="{{ url_for('catalog') }}" style="text-decoration: none;" class="product-button">Перейти в каталог</a>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<h2>Добавление товара</h2>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="add">
<label>Название товара:</label>
<input type="text" name="name" required>
<label>Цена:</label>
<input type="number" name="price" step="0.01" required>
<label>Описание:</label>
<textarea name="description" rows="4" required></textarea>
<label>Категория:</label>
<select name="category">
<option value="Без категории">Без категории</option>
{% for category in categories %}
<option value="{{ category }}">{{ category }}</option>
{% endfor %}
</select>
<label>Фотографии и видео (до 10):</label>
<input type="file" name="photos" accept="image/*,video/*" multiple>
<label>Цвета:</label>
<div id="color-inputs">
<div class="color-input-group">
<input type="text" name="colors" placeholder="Например: Красный">
<button type="button" class="remove-input-btn" onclick="this.parentNode.remove()">X</button>
</div>
</div>
<button type="button" class="add-color-btn" onclick="addInput('color-inputs', 'colors', 'Цвет')">Добавить цвет</button>
<label>Модели телефона:</label>
<div id="model-inputs">
<div class="model-input-group">
<input type="text" name="models" placeholder="Например: iPhone 14">
<button type="button" class="remove-input-btn" onclick="this.parentNode.remove()">X</button>
</div>
</div>
<button type="button" class="add-model-btn" onclick="addInput('model-inputs', 'models', 'Модель')">Добавить модель</button>
<button type="submit">Добавить товар</button>
</form>
<h2>Управление категориями</h2>
<form method="POST">
<input type="hidden" name="action" value="add_category">
<label>Название категории:</label>
<input type="text" name="category_name" required>
<button type="submit">Добавить</button>
</form>
<h2>Список категорий</h2>
<div class="category-list">
{% for category in categories %}
<div class="category-item">
<h3>{{ category }}</h3>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="delete_category">
<input type="hidden" name="category_name" value="{{ category }}">
<button type="submit" class="delete-button">Удалить</button>
</form>
</div>
{% endfor %}
</div>
<h2>Управление базой данных</h2>
<div>
<form method="POST" action="{{ url_for('backup') }}" style="display: inline;">
<button type="submit">Создать копию (вручную)</button>
</form>
<form method="GET" action="{{ url_for('download') }}" style="display: inline;">
<button type="submit">Скачать базу (JSON)</button>
</form>
</div>
<h2 style="margin-top: 40px;">Список товаров</h2>
<input type="text" id="admin-search-input" placeholder="Поиск товаров в админ-панели..." style="margin-bottom: 20px;">
<div class="product-list" id="admin-product-list">
{% for product in products %}
<div class="product-item"
data-name="{{ product['name']|lower }}"
data-description="{{ product['description']|lower }}"
data-category="{{ product.get('category', 'без категории')|lower }}">
<h3>{{ product['name'] }}</h3>
<p><strong>ID:</strong> {{ product.id }}</p>
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} с</p>
<p><strong>Описание:</strong> {{ product['description'] }}</p>
<p><strong>Цвета:</strong> {{ product.get('colors', ['Нет цветов'])|join(', ') }}</p>
<p><strong>Модели:</strong> {{ product.get('models', ['Нет моделей'])|join(', ') }}</p>
{% if product.get('photos') and product['photos']|length > 0 %}
<div class="product-photos-preview">
{% for photo in product['photos'] %}
{% if photo.endswith(('.mp4', '.mov', '.webm')) %}
<video src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}?r={{ random.randint(1,1000) }}" controls></video>
{% else %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}?r={{ random.randint(1,1000) }}">
{% endif %}
{% endfor %}
</div>
{% endif %}
<details>
<summary style="font-weight: 500; cursor: pointer; color: var(--primary-color); margin-top: 15px;">Редактировать</summary>
<form method="POST" enctype="multipart/form-data" style="margin-top:20px; box-shadow:none;">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="product_id" value="{{ product.id }}">
<label>Название:</label>
<input type="text" name="name" value="{{ product['name'] }}" required>
<label>Цена:</label>
<input type="number" name="price" step="0.01" value="{{ "%.2f"|format(product['price']) }}" required>
<label>Описание:</label>
<textarea name="description" rows="4" required>{{ product['description'] }}</textarea>
<label>Категория:</label>
<select name="category">
<option value="Без категории" {% if product.get('category', 'Без категории') == 'Без категории' %}selected{% endif %}>Без категории</option>
{% for category in categories %}
<option value="{{ category }}" {% if product.get('category') == category %}selected{% endif %}>{{ category }}</option>
{% endfor %}
</select>
<label>Фотографии и видео (заменят существующие):</label>
<input type="file" name="photos" accept="image/*,video/*" multiple>
<label>Цвета:</label>
<div id="edit-color-inputs-{{ product.id }}">
{% for color in product.get('colors', []) %}
<div class="color-input-group">
<input type="text" name="colors" value="{{ color }}">
<button type="button" class="remove-input-btn" onclick="this.parentNode.remove()">X</button>
</div>
{% endfor %}
<div class="color-input-group">
<input type="text" name="colors" placeholder="Например: Новый цвет">
<button type="button" class="remove-input-btn" onclick="this.parentNode.remove()">X</button>
</div>
</div>
<button type="button" class="add-color-btn" onclick="addInput('edit-color-inputs-{{ product.id }}', 'colors', 'Цвет')">Добавить цвет</button>
<label>Модели телефона:</label>
<div id="edit-model-inputs-{{ product.id }}">
{% for model in product.get('models', []) %}
<div class="model-input-group">
<input type="text" name="models" value="{{ model }}">
<button type="button" class="remove-input-btn" onclick="this.parentNode.remove()">X</button>
</div>
{% endfor %}
<div class="model-input-group">
<input type="text" name="models" placeholder="Например: Новая модель">
<button type="button" class="remove-input-btn" onclick="this.parentNode.remove()">X</button>
</div>
</div>
<button type="button" class="add-model-btn" onclick="addInput('edit-model-inputs-{{ product.id }}', 'models', 'Модель')">Добавить модель</button>
<button type="submit">Сохранить</button>
</form>
</details>
<form method="POST" style="margin-top: 15px; background:transparent; padding:0; box-shadow:none;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="product_id" value="{{ product.id }}">
<button type="submit" class="delete-button">Удалить товар</button>
</form>
</div>
{% endfor %}
</div>
</div>
<script>
function addInput(containerId, name, placeholderPrefix) {
const container = document.getElementById(containerId);
const newInput = document.createElement('div');
newInput.className = `${name.replace('s', '')}-input-group`;
newInput.innerHTML = `
<input type="text" name="${name}" placeholder="Например: ${placeholderPrefix}">
<button type="button" class="remove-input-btn" onclick="this.parentNode.remove()">X</button>
`;
container.appendChild(newInput);
}
document.getElementById('admin-search-input').addEventListener('input', filterAdminProducts);
function filterAdminProducts() {
const searchTerm = document.getElementById('admin-search-input').value.toLowerCase().trim();
document.querySelectorAll('.product-item').forEach(productItem => {
const name = productItem.getAttribute('data-name');
const description = productItem.getAttribute('data-description');
const category = productItem.getAttribute('data-category');
const matchesSearch = (name && name.includes(searchTerm)) || (description && description.includes(searchTerm)) || (category && category.includes(searchTerm));
productItem.style.display = matchesSearch ? 'block' : 'none';
});
}
</script>
</body>
</html>
''',
products=products,
categories=categories,
repo_id=REPO_ID,
LOGO_URL=LOGO_URL,
random=random
)
@app.route('/admin/orders')
def order_history():
data = load_data()
orders = list(data.get('orders', {}).values())
orders.sort(key=lambda x: x.get('date', ''), reverse=True)
return render_template_string('''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>История накладных</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>''' + BASE_STYLE + '''
body { padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; }
.header-buttons { display: flex; gap: 10px; flex-wrap: wrap; }
.btn-action { display: inline-block; padding: 8px 12px; margin: 2px; text-decoration: none; border-radius: var(--border-radius-small); font-size: 0.9rem; font-weight: 500; color: white; cursor: pointer; text-align: center; }
.btn-view { background-color: var(--primary-color); }
.btn-discount { background-color: var(--accent-color); }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-info">
<img src="''' + LOGO_URL + '''" alt="Logo" class="header-logo">
<h1>История накладных</h1>
</div>
<div class="header-buttons">
<a href="{{ url_for('admin') }}" class="product-button" style="text-decoration: none;">Админ-панель</a>
<a href="{{ url_for('catalog') }}" class="product-button" style="text-decoration: none;">В каталог</a>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="admin-table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>ID Заказа</th>
<th>Дата</th>
<th>Клиент</th>
<th>Телефон</th>
<th>Сумма (Итого)</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td>{{ order.id }}</td>
<td>{{ order.date }}</td>
<td>{{ order.name }}</td>
<td>{{ order.phone }}</td>
<td>{{ "%.2f"|format(order.get('final_total', order.total)) }} с</td>
<td>
<a href="{{ url_for('view_receipt', order_id=order.id) }}" class="btn-action btn-view" target="_blank">Посмотреть</a>
<a href="{{ url_for('apply_discount', order_id=order.id) }}" class="btn-action btn-discount">Сделать скидку</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" style="text-align: center;">Накладных пока нет.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</body>
</html>
''', orders=orders, LOGO_URL=LOGO_URL)
@app.route('/admin/orders/<order_id>/discount', methods=['GET', 'POST'])
def apply_discount(order_id):
data = load_data()
order = data.get('orders', {}).get(order_id)
if not order:
flash('Заказ не найден', 'error')
return redirect(url_for('order_history'))
if request.method == 'POST':
overall_disc = request.form.get('overall_discount', type=float)
if overall_disc is None:
overall_disc = 0.0
order['order_discount_percent'] = overall_disc
for idx, item in enumerate(order.get('cart', [])):
item_disc = request.form.get(f'item_discount_{idx}', type=float)
if item_disc is None:
item_disc = 0.0
item['discount_percent'] = item_disc
original_total = sum(item.get('price', 0) * item.get('quantity', 1) for item in order['cart'])
discounted_subtotal = 0
for item in order['cart']:
item_price = item.get('price', 0)
disc_pct = item.get('discount_percent', 0.0)
discounted_item_price = item_price * (1 - disc_pct / 100)
discounted_subtotal += discounted_item_price * item.get('quantity', 1)
final_total = discounted_subtotal * (1 - overall_disc / 100)
order['original_total'] = original_total
order['final_total'] = final_total
save_data(data)
flash('Скидка успешно применена к накладной.', 'success')
return redirect(url_for('order_history'))
return render_template_string('''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Скидка на заказ {{ order.id }}</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>''' + BASE_STYLE + '''
body { padding: 20px; }
.container { max-width: 900px; margin: 0 auto; }
form { background: var(--card-background-light); padding: 30px; border-radius: var(--border-radius-large); margin-bottom: 30px; box-shadow: var(--shadow-light); }
body.dark-mode form { background: var(--card-background-dark); }
label { font-weight: 600; margin-top: 15px; margin-bottom: 5px; display: block; }
input { width: 100%; padding: 12px; margin-bottom: 15px; border: 1px solid var(--border-color-light); border-radius: var(--border-radius-medium); font-size: 1rem; }
body.dark-mode input { border-color: var(--border-color-dark); background-color: var(--card-background-dark); color: var(--text-color-dark); }
.item-block { border: 1px solid var(--border-color-light); padding: 15px; margin-bottom: 15px; border-radius: var(--border-radius-medium); }
body.dark-mode .item-block { border-color: var(--border-color-dark); }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-info">
<img src="''' + LOGO_URL + '''" alt="Logo" class="header-logo">
<h1>Управление скидками</h1>
</div>
<a href="{{ url_for('order_history') }}" class="product-button" style="text-decoration: none; width: auto;">Назад к истории</a>
</div>
<form method="POST">
<h2 style="margin-bottom: 20px;">Скидка на накладную №{{ order.id }}</h2>
<label>Скидка на весь чек (%):</label>
<input type="number" name="overall_discount" step="0.01" min="0" max="100" value="{{ order.get('order_discount_percent', 0.0) }}">
<h3 style="margin-top: 30px; margin-bottom: 20px;">Скидка на отдельные позиции</h3>
{% for item in order.cart %}
<div class="item-block">
<p style="font-size: 1.1rem; margin-bottom: 10px;"><strong>{{ item.name }}</strong></p>
<p style="margin-bottom: 10px; color: var(--secondary-text-color-light);">
Цена: {{ "%.2f"|format(item.price) }} с | Кол-во: {{ item.quantity }} | Исходная сумма: {{ "%.2f"|format(item.price * item.quantity) }} с
</p>
<label>Скидка на эту позицию (%):</label>
<input type="number" name="item_discount_{{ loop.index0 }}" step="0.01" min="0" max="100" value="{{ item.get('discount_percent', 0.0) }}">
</div>
{% endfor %}
<button type="submit" class="product-button" style="margin-top: 20px;">Применить скидки и сохранить</button>
</form>
</div>
</body>
</html>
''', order=order)
@app.route('/backup', methods=['POST'])
def backup():
try:
data = load_data()
save_data(data)
flash('Резервная копия успешно создана и загружена.', 'success')
return redirect(url_for('admin'))
except Exception as e:
flash(f'Ошибка при создании резервной копии: {e}', 'error')
return redirect(url_for('admin'))
@app.route('/download', methods=['GET'])
def download():
try:
download_db_from_hf()
if os.path.exists(DATA_FILE):
return send_file(DATA_FILE, as_attachment=True, mimetype='application/json', download_name='data_zzirix_backup.json')
flash('Файл базы данных не найден после попытки скачивания.', 'error')
return redirect(url_for('admin'))
except Exception as e:
flash(f'Ошибка при скачивании базы данных: {e}', 'error')
return redirect(url_for('admin'))
if __name__ == '__main__':
uploads_dir = 'uploads'
os.makedirs(uploads_dir, exist_ok=True)
try:
current_data = load_data()
save_data(current_data)
except Exception as e:
pass
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
backup_thread.start()
app.run(debug=True, host='0.0.0.0', port=7860)