Tech / app.py
Kgshop's picture
Update app.py
571d72a verified
import os
import base64
import json
import threading
import time
from datetime import datetime
from uuid import uuid4
from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
from werkzeug.utils import secure_filename
from dotenv import load_dotenv
import requests
load_dotenv()
app = Flask(__name__)
app.secret_key = 'super_secret_key_store_app_123'
DATA_FILE = 'data.json'
SYNC_FILES = [DATA_FILE]
REPO_ID = os.getenv("REPO_ID", "Kgshop/tronberg")
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
WHATSAPP_NUMBER = "+77470623684"
CURRENCY_CODE = 'T'
LOGO_URL = "https://huggingface.co/spaces/Metapp/Tech/resolve/main/file_00000000916c71fa968384de18bef8ef.png"
def download_db_from_hf(specific_file=None, retries=3, delay=5):
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
files_to_download = [specific_file] if specific_file else SYNC_FILES
all_successful = True
for file_name in files_to_download:
success = False
for attempt in range(retries + 1):
try:
hf_hub_download(
repo_id=REPO_ID,
filename=file_name,
repo_type="dataset",
token=token_to_use,
local_dir=".",
local_dir_use_symlinks=False,
force_download=True,
resume_download=False
)
success = True
break
except RepositoryNotFoundError:
return False
except HfHubHTTPError as e:
if e.response.status_code == 404:
if attempt == 0 and not os.path.exists(file_name):
try:
if file_name == DATA_FILE:
with open(file_name, 'w', encoding='utf-8') as f:
json.dump({'products': [], 'categories': [], 'orders': {}}, f)
except Exception:
pass
success = False
break
except requests.exceptions.RequestException:
pass
except Exception:
pass
if attempt < retries:
time.sleep(delay)
if not success:
all_successful = False
return all_successful
def upload_db_to_hf(specific_file=None):
if not HF_TOKEN_WRITE:
return
try:
api = HfApi()
files_to_upload = [specific_file] if specific_file else SYNC_FILES
for file_name in files_to_upload:
if os.path.exists(file_name):
try:
api.upload_file(
path_or_fileobj=file_name,
path_in_repo=file_name,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
except Exception:
pass
except Exception:
pass
def periodic_backup():
while True:
time.sleep(1800)
upload_db_to_hf()
def load_data():
default_data = {'products': [], 'categories': [], 'orders': {}}
data = default_data
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
if not isinstance(data, dict):
raise FileNotFoundError
if 'products' not in data: data['products'] = []
if 'categories' not in data: data['categories'] = []
if 'orders' not in data: data['orders'] = {}
except (FileNotFoundError, json.JSONDecodeError):
if download_db_from_hf(specific_file=DATA_FILE):
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
if 'products' not in data: data['products'] = []
if 'categories' not in data: data['categories'] = []
if 'orders' not in data: data['orders'] = {}
except Exception:
data = default_data
else:
data = default_data
except Exception:
data = default_data
for product in data['products']:
if 'product_id' not in product:
product['product_id'] = uuid4().hex
if 'pieces_per_box' not in product:
product['pieces_per_box'] = 1
if not os.path.exists(DATA_FILE):
try:
with open(DATA_FILE, 'w', encoding='utf-8') as f:
json.dump(default_data, f)
except Exception:
pass
return data
def save_data(data):
try:
if not isinstance(data, dict):
return
if 'products' not in data: data['products'] = []
if 'categories' not in data: data['categories'] = []
if 'orders' not in data: data['orders'] = {}
with open(DATA_FILE, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
upload_db_to_hf(specific_file=DATA_FILE)
except Exception:
pass
CATALOG_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Магазин</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --primary: #1a1a1a; --bg: #f8f9fa; --surface: #ffffff; --text: #2d3436; --text-muted: #636e72; --border: #edf2f7; --accent: #25D366; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-tap-highlight-color: transparent; }
body { background-color: var(--bg); color: var(--text); padding-bottom: calc(90px + env(safe-area-inset-bottom)); }
.top-logo-container { background: var(--surface); padding: max(15px, env(safe-area-inset-top)) 20px 10px; text-align: center; border-bottom: 1px solid var(--border); }
.top-logo { max-width: 100%; height: auto; max-height: 80px; object-fit: contain; }
.header { display: flex; align-items: center; justify-content: space-between; padding: 15px 20px; background: var(--surface); box-shadow: 0 2px 10px rgba(0,0,0,0.03); position: sticky; top: 0; z-index: 100; }
.header h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.5px; }
.back-btn { display: none; font-size: 1.2rem; cursor: pointer; color: var(--text); margin-right: 15px; padding: 5px; }
.search-bar { padding: 15px 20px; background: var(--surface); border-bottom: 1px solid var(--border); }
.search-container { position: relative; display: flex; align-items: center; background: var(--bg); border-radius: 12px; padding: 0 15px; border: 1px solid transparent; transition: all 0.2s; }
.search-container:focus-within { border-color: #dcdde1; background: var(--surface); box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
.search-container i { color: var(--text-muted); font-size: 0.9rem; }
.search-bar input { width: 100%; padding: 12px 10px; border: none; background: transparent; outline: none; font-size: 0.95rem; }
.categories-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; padding: 20px; }
.category-item { background: var(--surface); padding: 20px 15px; border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; cursor: pointer; box-shadow: 0 4px 15px rgba(0,0,0,0.03); transition: transform 0.2s; text-align: center; }
.category-item:active { transform: scale(0.96); }
.category-item span.name { font-size: 0.95rem; font-weight: 600; line-height: 1.3; }
.category-item span.count { color: var(--text-muted); font-size: 0.8rem; background: var(--bg); padding: 4px 10px; border-radius: 20px; }
.products-container { display: none; padding: 20px; flex-direction: column; gap: 15px; }
.product-card { background: var(--surface); border-radius: 16px; padding: 12px; display: flex; box-shadow: 0 4px 15px rgba(0,0,0,0.03); align-items: stretch; gap: 15px; width: 100%; }
.product-img-wrapper { position: relative; width: 110px; height: 110px; flex-shrink: 0; }
.product-img { width: 100%; height: 100%; border-radius: 12px; object-fit: cover; cursor: pointer; background: var(--bg); border: 1px solid var(--border); }
.photo-count { position: absolute; bottom: 5px; right: 5px; background: rgba(0,0,0,0.6); color: white; font-size: 0.7rem; padding: 2px 6px; border-radius: 10px; pointer-events: none; }
.product-info { flex-grow: 1; display: flex; flex-direction: column; justify-content: space-between; min-width: 0; padding: 5px 0; }
.product-title { font-size: 0.95rem; font-weight: 600; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.product-desc { font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.product-box-info { font-size: 0.8rem; color: #00b894; margin-top: 4px; font-weight: 600; }
.product-bottom { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; flex-wrap: wrap; gap: 10px; }
.product-price { font-weight: 700; font-size: 1rem; color: var(--primary); }
.controls-wrapper { display: flex; gap: 8px; align-items: center; }
.quantity-control { display: flex; align-items: center; background: var(--bg); border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
.quantity-control button { border: none; background: transparent; width: 32px; height: 32px; font-size: 1.1rem; cursor: pointer; color: var(--primary); display: flex; align-items: center; justify-content: center; transition: background 0.2s; }
.quantity-control button:active { background: #e0e0e0; }
.quantity-control input { width: 36px; height: 32px; border: none; text-align: center; background: transparent; font-weight: 600; font-size: 0.95rem; color: var(--primary); outline: none; }
.quantity-control input[type="number"]::-webkit-inner-spin-button,
.quantity-control input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.quantity-control input[type="number"] { -moz-appearance: textfield; }
.box-btn { background: var(--primary); color: #fff; border: none; border-radius: 8px; padding: 0 10px; height: 32px; font-size: 0.8rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
.box-btn:active { opacity: 0.8; }
.cart-bar { position: fixed; bottom: 0; left: 0; width: 100%; background: var(--surface); box-shadow: 0 -4px 20px rgba(0,0,0,0.06); padding: 15px 20px calc(15px + env(safe-area-inset-bottom)); display: none; justify-content: space-between; align-items: center; z-index: 100; border-top-left-radius: 20px; border-top-right-radius: 20px; }
.cart-info { display: flex; flex-direction: column; }
.cart-total { font-size: 1.25rem; font-weight: 800; color: var(--primary); }
.checkout-btn { background: var(--primary); color: #fff; padding: 12px 28px; border: none; border-radius: 12px; font-weight: 600; font-size: 1rem; cursor: pointer; box-shadow: 0 4px 12px rgba(26,26,26,0.2); transition: transform 0.2s; }
.checkout-btn:active { transform: scale(0.95); }
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); z-index: 200; justify-content: center; align-items: flex-end; opacity: 0; transition: opacity 0.3s; }
.modal-overlay.active { opacity: 1; }
.modal-content { background: var(--surface); width: 100%; max-height: 85vh; border-radius: 24px 24px 0 0; padding: 25px 20px calc(25px + env(safe-area-inset-bottom)); overflow-y: auto; display: flex; flex-direction: column; transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1); }
.modal-overlay.active .modal-content { transform: translateY(0); }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; }
.modal-header h2 { font-size: 1.3rem; font-weight: 700; }
.modal-close { font-size: 1.5rem; cursor: pointer; border: none; background: #f1f2f6; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: var(--text); }
.customer-form { display: flex; flex-direction: column; gap: 12px; margin-bottom: 25px; }
.customer-form input { padding: 14px; border: 1px solid var(--border); border-radius: 12px; font-size: 0.95rem; background: var(--bg); outline: none; transition: border-color 0.2s; }
.customer-form input:focus { border-color: var(--primary); background: var(--surface); }
.cart-item-list { display: flex; flex-direction: column; gap: 15px; margin-bottom: 25px; }
.cart-item { display: flex; justify-content: space-between; align-items: center; background: var(--bg); padding: 15px; border-radius: 12px; flex-wrap: wrap; gap: 10px; }
.cart-item-name { flex: 1; min-width: 120px; font-size: 0.95rem; font-weight: 500; line-height: 1.3; }
.cart-item-controls { display: flex; align-items: center; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); overflow: hidden; }
.cart-item-controls button { border: none; background: transparent; width: 30px; height: 30px; font-size: 1rem; cursor: pointer; color: var(--primary); }
.cart-item-controls button:active { background: #e0e0e0; }
.cart-item-controls input { width: 35px; text-align: center; font-weight: 600; font-size: 0.9rem; border: none; background: transparent; color: var(--primary); outline: none; }
.cart-item-controls input[type="number"]::-webkit-inner-spin-button,
.cart-item-controls input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.cart-item-controls input[type="number"] { -moz-appearance: textfield; }
.cart-item-price { font-weight: 700; color: var(--primary); min-width: 70px; text-align: right; }
.cart-item-delete { color: #ff7675; background: none; border: none; font-size: 1.1rem; cursor: pointer; padding: 5px; }
.confirm-btn { background: var(--accent); color: #fff; width: 100%; padding: 16px; border: none; border-radius: 14px; font-size: 1.1rem; font-weight: 700; cursor: pointer; box-shadow: 0 4px 15px rgba(37,211,102,0.3); }
.gallery-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; z-index: 300; justify-content: center; align-items: center; flex-direction: column; }
.gallery-close { position: absolute; top: max(20px, env(safe-area-inset-top)); right: 20px; color: #fff; font-size: 2rem; cursor: pointer; background: rgba(0,0,0,0.5); width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: none; z-index: 302; }
.gallery-img-container { position: relative; width: 100%; height: 70vh; display: flex; align-items: center; justify-content: center; }
.gallery-img { max-width: 100%; max-height: 100%; object-fit: contain; }
.gallery-nav { position: absolute; top: 50%; transform: translateY(-50%); color: #fff; font-size: 2rem; background: rgba(0,0,0,0.5); border: none; width: 50px; height: 50px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; z-index: 301; }
.gallery-nav.prev { left: 10px; }
.gallery-nav.next { right: 10px; }
.gallery-dots { display: flex; gap: 8px; margin-top: 20px; }
.gallery-dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,0.3); transition: background 0.3s; }
.gallery-dot.active { background: #fff; }
.floating-socials { position: fixed; bottom: max(100px, calc(100px + env(safe-area-inset-bottom))); right: 15px; display: flex; flex-direction: column; gap: 12px; z-index: 90; }
.social-btn { width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 1.6rem; text-decoration: none; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 0.2s; }
.social-btn:active { transform: scale(0.9); }
.btn-float-wa { background: #25D366; }
.btn-float-ig { background: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%); }
.btn-float-tg { background: #0088cc; }
@media (min-width: 768px) {
.categories-container { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
.products-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); }
.modal-content { max-width: 500px; margin: 0 auto; border-radius: 24px; top: 50%; transform: translateY(-50%) scale(0.9); bottom: auto; position: relative; max-height: 90vh; }
.modal-overlay.active .modal-content { transform: translateY(-50%) scale(1); }
.cart-bar { max-width: 500px; left: 50%; transform: translateX(-50%); border-radius: 20px 20px 0 0; }
}
</style>
</head>
<body>
<div class="top-logo-container">
<img src="{{ logo_url }}" class="top-logo" alt="Логотип">
</div>
<div class="header">
<div style="display: flex; align-items: center;">
<i class="fas fa-arrow-left back-btn" id="backBtn" onclick="showCategories()"></i>
<h1 id="pageTitle">Каталог</h1>
</div>
</div>
<div class="search-bar" id="searchBar">
<div class="search-container">
<i class="fas fa-search"></i>
<input type="text" id="searchInput" placeholder="Поиск товаров..." oninput="filterCategories()">
</div>
</div>
<div class="categories-container" id="categoriesContainer"></div>
<div class="products-container" id="productsContainer"></div>
<div class="floating-socials">
<a href="https://wa.me/77011333885" class="social-btn btn-float-wa" target="_blank"><i class="fab fa-whatsapp"></i></a>
<a href="https://instagram.com/14sklad_baisat" class="social-btn btn-float-ig" target="_blank"><i class="fab fa-instagram"></i></a>
<a href="https://t.me/posuda15konteiner" class="social-btn btn-float-tg" target="_blank"><i class="fab fa-telegram-plane"></i></a>
</div>
<div class="cart-bar" id="cartBar">
<div class="cart-info">
<span style="font-size: 0.85rem; color: var(--text-muted); font-weight: 500;">Сумма заказа:</span>
<span class="cart-total"><span id="cartTotalSum">0</span> {{ currency_code }}</span>
</div>
<button class="checkout-btn" onclick="openCartModal()">Корзина <i class="fas fa-shopping-bag" style="margin-left:5px;"></i></button>
</div>
<div class="modal-overlay" id="cartModal" onclick="if(event.target === this) closeCartModal()">
<div class="modal-content">
<div class="modal-header">
<h2>Ваш заказ</h2>
<button class="modal-close" onclick="closeCartModal()"><i class="fas fa-times"></i></button>
</div>
<div class="cart-item-list" id="cartItemList"></div>
<div class="customer-form">
<input type="text" id="custName" placeholder="Ваше Имя" required>
<input type="text" id="custPhone" placeholder="Номер телефона" required>
<input type="text" id="custCity" placeholder="Город" required>
</div>
<button class="confirm-btn" onclick="submitOrder()">Оформить заказ</button>
</div>
</div>
<div class="gallery-modal" id="galleryModal">
<button class="gallery-close" onclick="closeGallery()"><i class="fas fa-times"></i></button>
<div class="gallery-img-container" id="gallerySwipeArea">
<button class="gallery-nav prev" onclick="prevPhoto(event)"><i class="fas fa-chevron-left"></i></button>
<img src="" class="gallery-img" id="galleryImage">
<button class="gallery-nav next" onclick="nextPhoto(event)"><i class="fas fa-chevron-right"></i></button>
</div>
<div class="gallery-dots" id="galleryDots"></div>
</div>
<script>
const products = {{ products_json|safe }};
const categoriesList = {{ categories_json|safe }};
const repoId = '{{ repo_id }}';
const currency = '{{ currency_code }}';
let cart = {};
let currentGalleryPhotos = [];
let currentGalleryIndex = 0;
function init() {
renderCategories();
updateCartUI();
}
function renderCategories() {
const container = document.getElementById('categoriesContainer');
const prodContainer = document.getElementById('productsContainer');
prodContainer.style.display = 'none';
container.style.display = 'grid';
document.getElementById('backBtn').style.display = 'none';
document.getElementById('pageTitle').innerText = 'Каталог';
container.innerHTML = '';
categoriesList.forEach(cat => {
const catProducts = products.filter(p => p.category === cat);
const count = catProducts.length;
const div = document.createElement('div');
div.className = 'category-item';
div.onclick = () => showProducts(cat);
div.innerHTML = `
<div style="background: var(--bg); width: 50px; height: 50px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 5px;">
<i class="fas fa-box-open" style="font-size: 1.5rem; color: var(--primary);"></i>
</div>
<span class="name">${cat}</span>
<span class="count">${count} шт</span>
`;
container.appendChild(div);
});
}
function showCategories() {
document.getElementById('searchInput').value = '';
renderCategories();
}
function filterCategories() {
const query = document.getElementById('searchInput').value.toLowerCase();
if (!query) {
renderCategories();
return;
}
document.getElementById('categoriesContainer').style.display = 'none';
const container = document.getElementById('productsContainer');
container.style.display = 'flex';
document.getElementById('backBtn').style.display = 'block';
document.getElementById('pageTitle').innerText = 'Поиск';
container.innerHTML = '';
const matchedProducts = products.filter(p =>
p.name.toLowerCase().includes(query) ||
p.category.toLowerCase().includes(query)
);
if(matchedProducts.length === 0) {
container.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">Ничего не найдено</div>';
} else {
matchedProducts.forEach(p => renderProductCard(p, container));
}
}
function formatQtyText(qty, ppb) {
ppb = parseInt(ppb) || 1;
if (ppb > 1 && qty >= ppb) {
let boxes = Math.floor(qty / ppb);
let remainder = qty % ppb;
return `${boxes} кор.` + (remainder > 0 ? ` ${remainder} шт.` : '');
}
return `${qty} шт.`;
}
function renderProductCard(p, container) {
const qty = cart[p.product_id] ? cart[p.product_id].quantity : 0;
const ppb = parseInt(p.pieces_per_box) || 1;
const hasPhotos = p.photos && p.photos.length > 0;
const photoUrl = hasPhotos
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p.photos[0]}`
: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiNhMGEwYTAiIGZvbnQtZmFtaWx5PSJzYW5zLXNlcmlmIiBmb250LXNpemU9IjE0Ij7QndC10YIg0YTQvtGC0L48L3RleHQ+PC9zdmc+';
const photoIndicator = hasPhotos && p.photos.length > 1 ? `<div class="photo-count"><i class="fas fa-images"></i> ${p.photos.length}</div>` : '';
const imgClick = hasPhotos ? `onclick="openGallery('${p.product_id}')"` : '';
const descHtml = p.description ? `<div class="product-desc">${p.description}</div>` : '';
const boxInfoHtml = ppb > 1 ? `<div class="product-box-info">В коробке: ${ppb} шт</div>` : '';
const addBoxBtn = ppb > 1 ? `<button class="box-btn" onclick="updateCart('${p.product_id}', ${ppb})">+ Коробка</button>` : '';
const div = document.createElement('div');
div.className = 'product-card';
div.innerHTML = `
<div class="product-img-wrapper" ${imgClick}>
<img src="${photoUrl}" class="product-img">
${photoIndicator}
</div>
<div class="product-info">
<div>
<div class="product-title">${p.name}</div>
${descHtml}
${boxInfoHtml}
</div>
<div class="product-bottom">
<div class="product-price">${p.price} ${currency}</div>
<div class="controls-wrapper">
${addBoxBtn}
<div class="quantity-control">
<button onclick="updateCart('${p.product_id}', -1)"><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
<input type="number" id="qty-${p.product_id}" value="${qty}" onchange="manualUpdateCart('${p.product_id}', this.value)">
<button onclick="updateCart('${p.product_id}', 1)"><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
</div>
</div>
</div>
</div>
`;
container.appendChild(div);
}
function showProducts(category) {
document.getElementById('categoriesContainer').style.display = 'none';
const container = document.getElementById('productsContainer');
container.style.display = 'flex';
document.getElementById('backBtn').style.display = 'block';
document.getElementById('pageTitle').innerText = category;
container.innerHTML = '';
const catProducts = products.filter(p => p.category === category);
if(catProducts.length === 0) {
container.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">В этой категории пока нет товаров</div>';
} else {
catProducts.forEach(p => renderProductCard(p, container));
}
}
function updateCart(productId, change, exactValue = null) {
const product = products.find(p => p.product_id === productId);
if (!product) return;
if (!cart[productId]) {
cart[productId] = { ...product, quantity: 0 };
}
if (exactValue !== null) {
cart[productId].quantity = exactValue;
} else {
cart[productId].quantity += change;
}
if (cart[productId].quantity <= 0) {
delete cart[productId];
const qtyInput = document.getElementById(`qty-${productId}`);
if (qtyInput) qtyInput.value = 0;
} else {
const qtyInput = document.getElementById(`qty-${productId}`);
if (qtyInput) qtyInput.value = cart[productId].quantity;
}
updateCartUI();
}
function manualUpdateCart(productId, val) {
let num = parseInt(val);
if (isNaN(num) || num < 0) num = 0;
updateCart(productId, 0, num);
}
function updateCartUI() {
let total = 0;
for (let id in cart) {
total += cart[id].price * cart[id].quantity;
}
const cartBar = document.getElementById('cartBar');
if (total > 0) {
cartBar.style.display = 'flex';
document.getElementById('cartTotalSum').innerText = total;
} else {
cartBar.style.display = 'none';
closeCartModal();
}
if (document.getElementById('cartModal').classList.contains('active')) {
renderCartModalItems();
}
}
function renderCartModalItems() {
const list = document.getElementById('cartItemList');
list.innerHTML = '';
for (let id in cart) {
const item = cart[id];
const ppb = parseInt(item.pieces_per_box) || 1;
const formattedQty = formatQtyText(item.quantity, ppb);
list.innerHTML += `
<div class="cart-item">
<div class="cart-item-name">
${item.name}
<div style="font-size: 0.8rem; color: #00b894; margin-top:2px;">${formattedQty}</div>
</div>
<div style="display:flex; align-items:center; gap: 10px;">
<div class="cart-item-controls">
<button onclick="updateCart('${id}', -1)"><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
<input type="number" value="${item.quantity}" onchange="manualUpdateCart('${id}', this.value)">
<button onclick="updateCart('${id}', 1)"><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
</div>
<button class="cart-item-delete" onclick="updateCart('${id}', 0, 0)"><i class="fas fa-trash-alt"></i></button>
</div>
<div class="cart-item-price">${item.price * item.quantity} ${currency}</div>
</div>
`;
}
}
function openCartModal() {
renderCartModalItems();
const modal = document.getElementById('cartModal');
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('active'), 10);
}
function closeCartModal() {
const modal = document.getElementById('cartModal');
modal.classList.remove('active');
setTimeout(() => modal.style.display = 'none', 300);
}
function submitOrder() {
const cartArray = Object.values(cart);
if(cartArray.length === 0) return;
const name = document.getElementById('custName').value.trim();
const phone = document.getElementById('custPhone').value.trim();
const city = document.getElementById('custCity').value.trim();
if(!name || !phone || !city) {
alert('Пожалуйста, заполните все поля (Имя, Телефон, Город)');
return;
}
const btn = document.querySelector('.confirm-btn');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Оформление...';
btn.disabled = true;
fetch('/create_order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cart: cartArray,
customer_name: name,
customer_phone: phone,
customer_city: city
})
})
.then(r => r.json())
.then(data => {
if(data.order_id) {
cart = {};
window.location.href = `/order/${data.order_id}`;
}
})
.catch(() => {
btn.innerHTML = 'Оформить заказ';
btn.disabled = false;
alert('Произошла ошибка. Попробуйте еще раз.');
});
}
function openGallery(productId) {
const product = products.find(p => p.product_id === productId);
if (!product || !product.photos || product.photos.length === 0) return;
currentGalleryPhotos = product.photos.map(p => `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p}`);
currentGalleryIndex = 0;
document.getElementById('galleryModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
updateGalleryView();
}
function closeGallery() {
document.getElementById('galleryModal').style.display = 'none';
document.body.style.overflow = '';
}
function updateGalleryView() {
document.getElementById('galleryImage').src = currentGalleryPhotos[currentGalleryIndex];
const dotsContainer = document.getElementById('galleryDots');
dotsContainer.innerHTML = '';
if(currentGalleryPhotos.length > 1) {
currentGalleryPhotos.forEach((_, index) => {
const dot = document.createElement('div');
dot.className = `gallery-dot ${index === currentGalleryIndex ? 'active' : ''}`;
dotsContainer.appendChild(dot);
});
document.querySelectorAll('.gallery-nav').forEach(el => el.style.display = 'flex');
} else {
document.querySelectorAll('.gallery-nav').forEach(el => el.style.display = 'none');
}
}
function nextPhoto(e) {
if(e) e.stopPropagation();
if(currentGalleryPhotos.length <= 1) return;
currentGalleryIndex = (currentGalleryIndex + 1) % currentGalleryPhotos.length;
updateGalleryView();
}
function prevPhoto(e) {
if(e) e.stopPropagation();
if(currentGalleryPhotos.length <= 1) return;
currentGalleryIndex = (currentGalleryIndex - 1 + currentGalleryPhotos.length) % currentGalleryPhotos.length;
updateGalleryView();
}
let touchstartX = 0;
let touchendX = 0;
const swipeArea = document.getElementById('gallerySwipeArea');
swipeArea.addEventListener('touchstart', e => { touchstartX = e.changedTouches[0].screenX; });
swipeArea.addEventListener('touchend', e => {
touchendX = e.changedTouches[0].screenX;
if (touchstartX - touchendX > 50) nextPhoto();
if (touchendX - touchstartX > 50) prevPhoto();
});
init();
</script>
</body>
</html>
'''
ORDER_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Накладная №{{ order.id }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg: #f4f6f8; --surface: #ffffff; --text: #2d3436; --border: #dfe6e9; --wa: #25D366; --print: #1e272e; --primary: #1a1a1a; }
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { margin: 0; padding: max(20px, env(safe-area-inset-top)) 20px calc(100px + env(safe-area-inset-bottom)); background: var(--bg); display: flex; flex-direction: column; align-items: center; color: var(--text); }
.invoice-box { background: var(--surface); width: 100%; max-width: 900px; padding: 30px; box-shadow: 0 4px 20px rgba(0,0,0,0.05); border-radius: 16px; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; border-bottom: 2px solid var(--border); padding-bottom: 15px; flex-wrap: wrap; gap: 10px; }
.header h1 { margin: 0; font-size: 1.8rem; font-weight: 800; }
.info-row { display: flex; justify-content: space-between; margin-bottom: 20px; font-size: 1rem; flex-wrap: wrap; gap: 15px; }
.customer-details { display: flex; flex-direction: column; gap: 6px; }
.customer-details span { font-weight: 600; color: #1a1a1a; }
.table-responsive { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin-bottom: 20px; border-radius: 8px; border: 1px solid var(--border); }
table { width: 100%; border-collapse: collapse; min-width: 600px; }
th, td { border-bottom: 1px solid var(--border); padding: 12px; text-align: center; font-size: 0.95rem; }
th { background: #fafafa; font-weight: 700; color: #636e72; text-transform: uppercase; font-size: 0.8rem; letter-spacing: 0.5px; }
.img-cell img { width: 45px; height: 45px; object-fit: cover; border-radius: 8px; border: 1px solid #eee; }
.total-row { background: #fafafa; font-weight: 800; }
.total-row td { font-size: 1.1rem; border-bottom: none; }
.cart-item-controls { display: inline-flex; align-items: center; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); overflow: hidden; margin-bottom: 5px; }
.cart-item-controls button { border: none; background: #f8f9fa; width: 30px; height: 30px; font-size: 1rem; cursor: pointer; color: var(--primary); transition: background 0.2s; }
.cart-item-controls button:active { background: #e0e0e0; }
.cart-item-controls input { width: 40px; text-align: center; font-weight: 600; font-size: 0.95rem; border: none; background: transparent; color: var(--primary); outline: none; }
.cart-item-controls input[type="number"]::-webkit-inner-spin-button,
.cart-item-controls input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.cart-item-controls input[type="number"] { -moz-appearance: textfield; }
.screen-only { display: block; }
.print-only { display: none; }
.action-bar { position: fixed; bottom: 0; left: 0; width: 100%; background: var(--surface); box-shadow: 0 -4px 20px rgba(0,0,0,0.08); padding: 15px 20px calc(15px + env(safe-area-inset-bottom)); display: flex; gap: 15px; z-index: 100; justify-content: center; border-top-left-radius: 20px; border-top-right-radius: 20px; }
.action-bar-inner { display: flex; gap: 15px; width: 100%; max-width: 900px; }
.btn { flex: 1; padding: 15px 10px; border-radius: 12px; border: none; font-size: 1rem; font-weight: 700; cursor: pointer; color: #fff; display: flex; align-items: center; justify-content: center; gap: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); transition: transform 0.2s; white-space: nowrap; }
.btn:active { transform: scale(0.96); }
.btn-wa { background: var(--wa); box-shadow: 0 4px 15px rgba(37,211,102,0.3); }
.btn-print { background: var(--print); }
.btn-home { background: #0984e3; box-shadow: 0 4px 15px rgba(9,132,227,0.3); flex: 0 0 auto; padding: 15px 20px; }
#loadingOverlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.8); z-index: 999; justify-content: center; align-items: center; font-size: 2rem; color: var(--primary); }
@media print {
body { background: #fff; padding: 0; }
.invoice-box { box-shadow: none; padding: 0; max-width: 100%; border-radius: 0; }
.table-responsive { border: none; overflow: visible; }
table { min-width: 100%; }
th, td { border: 1px solid #000; }
.action-bar, .screen-only { display: none !important; }
.print-only { display: block !important; }
}
@media (max-width: 600px) {
.header h1 { font-size: 1.4rem; }
.info-row { font-size: 0.9rem; }
.invoice-box { padding: 20px 15px; }
.btn { font-size: 0.9rem; flex-direction: column; padding: 10px; gap: 4px; }
.btn i { font-size: 1.2rem; }
}
</style>
</head>
<body>
<div id="loadingOverlay"><i class="fas fa-spinner fa-spin"></i></div>
<div class="invoice-box">
<div style="text-align: center; margin-bottom: 25px;">
<img src="{{ logo_url }}" style="max-height: 80px; max-width: 100%; object-fit: contain;">
</div>
<div class="header">
<h1>Накладная</h1>
<div style="text-align: right;">
<div style="font-size: 1.1rem; font-weight: bold;">№ {{ order.id }}</div>
<div style="color: #636e72; font-size: 0.9rem;">{{ order.created_at.split(' ')[0] }}</div>
</div>
</div>
<div class="info-row">
<div class="customer-details">
<div>Покупатель: <span>{{ order.customer_name }}</span></div>
<div>Телефон: <span>{{ order.customer_phone }}</span></div>
<div>Город: <span>{{ order.customer_city }}</span></div>
</div>
<div style="font-weight: 600;">Статус: <span style="color: #00b894;">Новый</span></div>
</div>
<div class="table-responsive">
<table>
<thead>
<tr>
<th style="width: 50px;">№</th>
<th style="text-align: left;">Наименование</th>
<th>Фото</th>
<th>Кол-во</th>
<th>Цена</th>
<th>Сумма</th>
</tr>
</thead>
<tbody>
{% set raw_total = 0 %}
{% for item in order.cart %}
{% set ppb = item.pieces_per_box|default(1)|int %}
{% set boxes = item.quantity // ppb %}
{% set remainder = item.quantity % ppb %}
{% set item_sum = item.price * item.quantity %}
{% set raw_total = raw_total + item_sum %}
<tr>
<td>{{ loop.index }}</td>
<td style="text-align: left; font-weight: 500;">{{ item.name }}</td>
<td class="img-cell"><img src="{{ item.photo_url }}" alt="img"></td>
<td style="text-align: center;">
<div class="screen-only">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<div style="display:flex; align-items:center; gap:8px;">
<div class="cart-item-controls">
<button onclick="updateItem('{{ item.product_id }}', -1)"><i class="fas fa-minus" style="font-size:0.7rem;"></i></button>
<input type="number" value="{{ item.quantity }}" onchange="manualUpdateOrder('{{ item.product_id }}', this.value)">
<button onclick="updateItem('{{ item.product_id }}', 1)"><i class="fas fa-plus" style="font-size:0.7rem;"></i></button>
</div>
<button onclick="updateItem('{{ item.product_id }}', 0, true)" style="color: #ff7675; background: none; border: none; font-size: 1.1rem; cursor: pointer; padding: 5px;"><i class="fas fa-trash-alt"></i></button>
</div>
<div style="font-size: 0.85rem; color: #00b894; font-weight: 600;">
{% if ppb > 1 and boxes > 0 %}
{{ boxes }} кор.{% if remainder > 0 %} {{ remainder }} шт.{% endif %}
{% else %}
{{ item.quantity }} шт.
{% endif %}
</div>
</div>
</div>
<div class="print-only" style="font-weight: bold;">
{% if ppb > 1 and boxes > 0 %}
{{ boxes }} кор.{% if remainder > 0 %} {{ remainder }} шт.{% endif %}
{% else %}
{{ item.quantity }} шт.
{% endif %}
</div>
</td>
<td>{{ item.price }}</td>
<td>{{ item_sum }}</td>
</tr>
{% endfor %}
{% set discount = order.discount|default(0)|float %}
{% if discount > 0 %}
<tr class="total-row" style="background:transparent;">
<td colspan="5" style="text-align: right; padding-right: 20px; font-weight:600; color:#636e72;">Сумма:</td>
<td style="font-weight:600; color:#636e72;">{{ raw_total }} {{ currency_code }}</td>
</tr>
<tr class="total-row" style="background:transparent;">
<td colspan="5" style="text-align: right; padding-right: 20px; font-weight:600; color:#ff7675;">Скидка:</td>
<td style="font-weight:600; color:#ff7675;">-{{ discount }} {{ currency_code }}</td>
</tr>
{% endif %}
<tr class="total-row">
<td colspan="5" style="text-align: right; padding-right: 20px;">К оплате:</td>
<td style="color:var(--wa);">{{ order.total_price }} {{ currency_code }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="action-bar">
<div class="action-bar-inner">
<a href="/" class="btn btn-home"><i class="fas fa-home"></i></a>
<button class="btn btn-print" onclick="window.print()"><i class="fas fa-print"></i> Печать</button>
<button class="btn btn-wa" onclick="sendToWA()"><i class="fab fa-whatsapp" style="font-size: 1.2rem;"></i> WhatsApp</button>
</div>
</div>
<script>
function sendToWA() {
let msg = `Здравствуйте! Мой заказ №{{ order.id }}\nНакладная: ${window.location.href}`;
window.open(`https://api.whatsapp.com/send?phone={{ whatsapp_number }}&text=${encodeURIComponent(msg)}`, '_blank');
}
function updateItem(productId, change, isRemove = false) {
document.getElementById('loadingOverlay').style.display = 'flex';
fetch(`/edit_order/{{ order.id }}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product_id: productId, change: change, remove: isRemove })
})
.then(r => r.json())
.then(data => {
if(data.success) {
window.location.reload();
} else {
alert('Ошибка обновления');
document.getElementById('loadingOverlay').style.display = 'none';
}
})
.catch(() => {
alert('Произошла ошибка');
document.getElementById('loadingOverlay').style.display = 'none';
});
}
function manualUpdateOrder(productId, val) {
let num = parseInt(val);
if (isNaN(num) || num < 0) return;
document.getElementById('loadingOverlay').style.display = 'flex';
fetch(`/edit_order/{{ order.id }}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product_id: productId, exact_qty: num })
})
.then(r => r.json())
.then(data => {
if(data.success) {
window.location.reload();
} else {
alert('Ошибка обновления');
document.getElementById('loadingOverlay').style.display = 'none';
}
})
.catch(() => {
alert('Произошла ошибка');
document.getElementById('loadingOverlay').style.display = 'none';
});
}
</script>
</body>
</html>
'''
ADMIN_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Админ-панель</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --primary: #2d3436; --bg: #f4f6f9; --surface: #ffffff; --border: #e0e6ed; --danger: #ff7675; --success: #00b894; --info: #0984e3; --warning: #f39c12; }
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { background: var(--bg); padding: max(20px, env(safe-area-inset-top)) 15px calc(20px + env(safe-area-inset-bottom)); margin: 0; color: #2d3436; }
.container { max-width: 1000px; margin: 0 auto; }
.header-panel { background: var(--surface); padding: 20px; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
.header-panel h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
.btn { padding: 12px 20px; border: none; border-radius: 10px; font-weight: 600; cursor: pointer; color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; font-size: 0.95rem; transition: opacity 0.2s; }
.btn:active { opacity: 0.8; }
.btn-primary { background: var(--info); }
.btn-success { background: var(--success); }
.btn-danger { background: var(--danger); padding: 8px 15px; font-size: 0.85rem; }
.btn-warning { background: var(--warning); padding: 8px 15px; font-size: 0.85rem; }
.btn-dark { background: var(--primary); }
.sync-panel { display: flex; gap: 10px; margin-bottom: 25px; flex-wrap: wrap; }
.sync-panel form { flex: 1; min-width: 200px; }
.sync-panel button { width: 100%; }
.card { background: var(--surface); padding: 20px; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; }
.card h2 { margin-top: 0; margin-bottom: 15px; font-size: 1.2rem; }
input[type="text"], input[type="number"], select, textarea { width: 100%; padding: 12px 15px; border: 1px solid var(--border); border-radius: 10px; font-size: 0.95rem; outline: none; transition: border-color 0.2s; background: #fafafa; }
input[type="text"]:focus, input[type="number"]:focus, textarea:focus { border-color: var(--info); background: #fff; }
textarea { resize: vertical; min-height: 80px; font-family: inherit; }
.add-cat-form { display: flex; gap: 10px; flex-wrap: wrap; }
.add-cat-form input { flex: 1; min-width: 200px; }
.add-cat-form button { white-space: nowrap; }
.search-bar-admin { position: relative; margin-bottom: 20px; }
.search-bar-admin i { position: absolute; left: 15px; top: 50%; transform: translateY(-50%); color: #636e72; }
.search-bar-admin input { padding-left: 40px; background: var(--surface); border: none; box-shadow: 0 4px 15px rgba(0,0,0,0.03); }
.category-block { border: 1px solid var(--border); margin-bottom: 15px; border-radius: 12px; overflow: hidden; background: #fff; }
.category-header { background: #fafafa; padding: 15px 20px; font-weight: 700; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.2s; }
.category-header:hover { background: #f0f0f0; }
.category-content { padding: 0; display: none; }
.category-content.active { display: block; }
.product-item { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 1px solid var(--border); flex-wrap: wrap; gap: 10px; }
.product-item:last-child { border-bottom: none; }
.product-info { display: flex; align-items: center; gap: 15px; min-width: 250px; flex: 1; }
.product-img { width: 50px; height: 50px; object-fit: cover; border-radius: 8px; border: 1px solid #eee; background: #fafafa; }
.product-details { display: flex; flex-direction: column; }
.product-name { font-weight: 600; font-size: 0.95rem; }
.product-desc { font-size: 0.85rem; color: #636e72; margin-top: 2px; }
.product-meta { font-size: 0.8rem; color: #b2bec3; margin-top: 4px; }
.product-actions { display: flex; gap: 5px; }
.add-product-wrapper { display: none; }
.add-product-wrapper.active { display: block; }
.toggle-add-product { width: 100%; text-align: center; background: #fafafa; padding: 15px; cursor: pointer; color: var(--success); font-weight: 600; transition: background 0.2s; border-bottom: 1px solid var(--border); }
.toggle-add-product:hover { background: #f0f0f0; }
.add-product-form { background: #fdfdfd; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
.form-row { display: flex; gap: 10px; flex-wrap: wrap; }
.form-row > * { flex: 1; min-width: 150px; }
.file-input-wrapper { position: relative; width: 100%; }
input[type="file"] { width: 100%; padding: 10px; border: 1px dashed #ccc; border-radius: 10px; background: #fafafa; font-size: 0.9rem; }
.orders-table { width: 100%; border-collapse: collapse; min-width: 800px; text-align: left; }
.orders-table th { padding: 12px; background: #fafafa; border-bottom: 2px solid var(--border); color: #636e72; font-size: 0.85rem; text-transform: uppercase; }
.orders-table td { padding: 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.orders-table tr:hover { background: #fafafa; }
@media (max-width: 600px) {
.header-panel { flex-direction: column; align-items: stretch; text-align: center; }
.product-item { flex-direction: column; align-items: stretch; }
.product-info { width: 100%; }
.product-actions { align-self: flex-end; }
.form-row { flex-direction: column; }
}
</style>
</head>
<body>
<div class="container">
<div class="header-panel">
<h1><i class="fas fa-cog"></i> Админ-панель</h1>
<a href="/" class="btn btn-primary"><i class="fas fa-store"></i> В каталог</a>
</div>
<div class="sync-panel">
<form method="POST" action="/force_upload" onsubmit="showLoading(this)">
<button type="submit" class="btn btn-success"><i class="fas fa-cloud-upload-alt"></i> Сохранить на сервер</button>
</form>
<form method="POST" action="/force_download" onsubmit="showLoading(this)">
<button type="submit" class="btn btn-info" style="background:#0984e3;"><i class="fas fa-cloud-download-alt"></i> Скачать с сервера</button>
</form>
</div>
<div class="card" style="padding: 0;">
<div class="category-header" onclick="toggleCategory('orders-history')" style="border-radius: 16px; border-bottom: none;">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-down" id="icon-orders-history" style="color: #636e72;"></i>
<span style="font-weight: 800; font-size: 1.2rem; color: #2d3436;"><i class="fas fa-file-invoice-dollar" style="color:var(--info);"></i> История накладных</span>
</div>
</div>
<div class="category-content" id="orders-history" style="padding: 0 20px 20px 20px; border-top: 1px solid var(--border);">
<div style="overflow-x: auto; padding-top: 15px;">
<table class="orders-table">
<tr>
<th>ID / Дата</th>
<th>Клиент</th>
<th>Сумма</th>
<th>Скидка</th>
<th>К оплате</th>
<th>Действия</th>
</tr>
{% for order in orders.values()|sort(attribute='created_at', reverse=True) %}
{% set raw_total = 0 %}
{% for item in order.cart %}
{% set raw_total = raw_total + (item.price|float * item.quantity|int) %}
{% endfor %}
<tr>
<td>
<a href="/order/{{ order.id }}" target="_blank" style="color:var(--info); font-weight:bold; text-decoration:none;">{{ order.id }}</a><br>
<span style="font-size:0.8rem; color:#636e72;">{{ order.created_at }}</span>
</td>
<td style="font-size:0.9rem;">
{{ order.customer_name }}<br>
<i class="fas fa-phone" style="font-size:0.7rem; color:#999;"></i> {{ order.customer_phone }}<br>
<i class="fas fa-map-marker-alt" style="font-size:0.7rem; color:#999;"></i> {{ order.customer_city }}
</td>
<td style="font-weight:600;">{{ raw_total }} {{ currency_code }}</td>
<td>
<form method="POST" style="display:flex; gap:5px; margin:0; align-items:center;">
<input type="hidden" name="action" value="apply_discount">
<input type="hidden" name="order_id" value="{{ order.id }}">
<input type="number" name="discount_amount" value="{{ order.discount|default(0) }}" min="0" step="0.01" style="width:80px; padding:6px; font-size:0.9rem;">
<button type="submit" class="btn btn-warning" style="padding:6px 10px;" title="Применить скидку"><i class="fas fa-check"></i></button>
</form>
</td>
<td style="font-weight:800; color:var(--success);">{{ order.total_price }} {{ currency_code }}</td>
<td>
<a href="/order/{{ order.id }}" class="btn btn-primary" style="padding:6px 10px;" target="_blank"><i class="fas fa-eye"></i></a>
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
<div class="card">
<h2>Управление категориями</h2>
<form method="POST" class="add-cat-form">
<input type="hidden" name="action" value="add_category">
<input type="text" name="category_name" placeholder="Название новой категории" required autocomplete="off">
<button type="submit" class="btn btn-dark"><i class="fas fa-plus"></i> Добавить</button>
</form>
</div>
<div class="search-bar-admin">
<i class="fas fa-search"></i>
<input type="text" id="adminSearch" placeholder="Поиск по категориям и товарам..." oninput="filterAdmin()">
</div>
{% for category in categories %}
<div class="category-block">
<div class="category-header" onclick="toggleCategory('cat-{{ loop.index }}')">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-down" id="icon-cat-{{ loop.index }}" style="color: #636e72;"></i>
<span class="cat-title-text"><i class="fas fa-folder-open" style="color:var(--info); margin-right:5px;"></i> {{ category }}</span>
</div>
<form method="POST" style="margin:0;" onclick="event.stopPropagation();" onsubmit="return confirm('Удалить категорию и все ее товары?');">
<input type="hidden" name="action" value="delete_category">
<input type="hidden" name="category_name" value="{{ category }}">
<button type="submit" class="btn btn-danger"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
<div class="category-content" id="cat-{{ loop.index }}">
<div class="toggle-add-product" onclick="toggleAddProduct('add-prod-{{ loop.index }}')">
<i class="fas fa-plus"></i> Добавить товар
</div>
<div class="add-product-wrapper" id="add-prod-{{ loop.index }}">
<form class="add-product-form" method="POST" enctype="multipart/form-data" onsubmit="showLoading(this)">
<input type="hidden" name="action" value="add_product">
<input type="hidden" name="category" value="{{ category }}">
<div style="font-weight: 600; font-size: 0.9rem; color: #636e72;">Новый товар в категории "{{ category }}"</div>
<div class="form-row">
<input type="text" name="name" placeholder="Название товара" required autocomplete="off" style="flex:2;">
<input type="number" name="price" placeholder="Цена" required step="0.01" style="flex:1;">
<input type="number" name="pieces_per_box" placeholder="В коробке (шт)" value="1" min="1" required style="flex:1;">
</div>
<textarea name="description" placeholder="Описание товара (необязательно)"></textarea>
<div class="file-input-wrapper">
<input type="file" name="photos" accept="image/*" multiple max="10" required>
<div style="font-size: 0.8rem; color: #999; margin-top: 5px;">Можно выбрать до 10 фото</div>
</div>
<button type="submit" class="btn btn-success" style="width: 100%; justify-content: center;"><i class="fas fa-check"></i> Сохранить товар</button>
</form>
</div>
{% for product in products %}
{% if product.category == category %}
<div class="product-item">
<div class="product-info">
{% if product.photos and product.photos|length > 0 %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product.photos[0] }}" class="product-img">
{% else %}
<div class="product-img" style="display:flex;align-items:center;justify-content:center;color:#ccc;"><i class="fas fa-image"></i></div>
{% endif %}
<div class="product-details">
<span class="product-name">{{ product.name }}</span>
{% if product.description %}
<span class="product-desc">{{ product.description[:50] }}{{ '...' if product.description|length > 50 else '' }}</span>
{% endif %}
<span class="product-meta">{{ product.price }} {{ currency_code }} • В коробке: {{ product.pieces_per_box|default(1) }} шт • Фото: {{ product.photos|length if product.photos else 0 }}/10</span>
</div>
</div>
<div class="product-actions">
<button class="btn btn-warning" onclick="toggleEditProduct('edit-prod-{{ product.product_id }}')"><i class="fas fa-edit"></i></button>
<form method="POST" style="margin:0;" onsubmit="return confirm('Удалить товар?');">
<input type="hidden" name="action" value="delete_product">
<input type="hidden" name="product_id" value="{{ product.product_id }}">
<button type="submit" class="btn btn-danger"><i class="fas fa-times"></i></button>
</form>
</div>
<div class="add-product-wrapper" id="edit-prod-{{ product.product_id }}" style="width: 100%; margin-top: 15px; border-top: 1px dashed var(--border); padding-top: 15px;">
<form class="add-product-form" method="POST" enctype="multipart/form-data" onsubmit="showLoading(this)" style="padding: 0;">
<input type="hidden" name="action" value="edit_product">
<input type="hidden" name="product_id" value="{{ product.product_id }}">
<input type="hidden" name="category" value="{{ category }}">
<div style="font-weight: 600; font-size: 0.9rem; color: #636e72;">Редактирование товара</div>
<div class="form-row">
<input type="text" name="name" value="{{ product.name }}" required autocomplete="off" style="flex:2;">
<input type="number" name="price" value="{{ product.price }}" required step="0.01" style="flex:1;">
<input type="number" name="pieces_per_box" value="{{ product.pieces_per_box|default(1) }}" min="1" required style="flex:1;">
</div>
<textarea name="description">{{ product.description }}</textarea>
<div class="file-input-wrapper">
<input type="file" name="photos" accept="image/*" multiple max="10">
<div style="font-size: 0.8rem; color: #999; margin-top: 5px;">Оставьте пустым, чтобы не менять фото</div>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;"><i class="fas fa-save"></i> Сохранить изменения</button>
</form>
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<script>
function showLoading(form) {
const btn = form.querySelector('button[type="submit"]');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Загрузка...';
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.7';
}
function toggleCategory(id) {
const content = document.getElementById(id);
const icon = document.getElementById('icon-' + id);
if(content.classList.contains('active')) {
content.classList.remove('active');
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
} else {
content.classList.add('active');
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
}
}
function toggleAddProduct(id) {
const form = document.getElementById(id);
form.classList.toggle('active');
}
function toggleEditProduct(id) {
const form = document.getElementById(id);
form.classList.toggle('active');
}
function filterAdmin() {
const query = document.getElementById('adminSearch').value.toLowerCase();
const categories = document.querySelectorAll('.category-block');
categories.forEach(cat => {
const catName = cat.querySelector('.cat-title-text').innerText.toLowerCase();
const products = cat.querySelectorAll('.product-item');
let catMatch = catName.includes(query);
let hasVisibleProduct = false;
products.forEach(prod => {
const prodName = prod.querySelector('.product-name').innerText.toLowerCase();
if (prodName.includes(query) || catMatch) {
prod.style.display = 'flex';
hasVisibleProduct = true;
} else {
prod.style.display = 'none';
}
});
if (catMatch || hasVisibleProduct) {
cat.style.display = 'block';
if (query && hasVisibleProduct) {
cat.querySelector('.category-content').classList.add('active');
cat.querySelector('.fas.fa-chevron-down, .fas.fa-chevron-up').className = 'fas fa-chevron-up';
}
} else {
cat.style.display = 'none';
}
if (!query) {
cat.querySelector('.category-content').classList.remove('active');
cat.querySelector('.fas.fa-chevron-up, .fas.fa-chevron-down').className = 'fas fa-chevron-down';
}
});
}
</script>
</body>
</html>
'''
@app.route('/')
def catalog():
data = load_data()
all_products = data.get('products', [])
categories = data.get('categories', [])
return render_template_string(
CATALOG_TEMPLATE,
products_json=json.dumps(all_products),
categories_json=json.dumps(categories),
repo_id=REPO_ID,
currency_code=CURRENCY_CODE,
logo_url=LOGO_URL
)
@app.route('/create_order', methods=['POST'])
def create_order():
order_data = request.get_json()
if not order_data or 'cart' not in order_data:
return jsonify({"error": "Bad request"}), 400
cart_items = order_data['cart']
total_price = sum(float(item['price']) * int(item['quantity']) for item in cart_items)
customer_name = order_data.get('customer_name', 'Не указано')
customer_phone = order_data.get('customer_phone', 'Не указано')
customer_city = order_data.get('customer_city', 'Не указано')
processed_cart = []
for item in cart_items:
processed_cart.append({
"product_id": item.get('product_id'),
"name": item['name'],
"price": float(item['price']),
"quantity": int(item['quantity']),
"pieces_per_box": int(item.get('pieces_per_box', 1)),
"photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photos'][0]}" if item.get('photos') else "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PC9zdmc+"
})
order_id = f"SA-{datetime.now().strftime('%Y%m%d')}-{str(len(load_data().get('orders', {}))+1).zfill(3)}"
new_order = {
"id": order_id,
"created_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
"cart": processed_cart,
"discount": 0,
"total_price": total_price,
"customer_name": customer_name,
"customer_phone": customer_phone,
"customer_city": customer_city
}
data = load_data()
data['orders'][order_id] = new_order
save_data(data)
return jsonify({"order_id": order_id}), 201
@app.route('/order/<order_id>')
def view_order(order_id):
data = load_data()
order = data.get('orders', {}).get(order_id)
if not order:
return "Order not found", 404
return render_template_string(
ORDER_TEMPLATE,
order=order,
whatsapp_number=WHATSAPP_NUMBER,
currency_code=CURRENCY_CODE,
logo_url=LOGO_URL
)
@app.route('/edit_order/<order_id>', methods=['POST'])
def edit_order(order_id):
data = load_data()
order = data.get('orders', {}).get(order_id)
if not order:
return jsonify({"success": False, "error": "Order not found"}), 404
req_data = request.get_json()
product_id = req_data.get('product_id')
change = req_data.get('change', 0)
exact_qty = req_data.get('exact_qty')
remove = req_data.get('remove', False)
for item in order['cart']:
if item.get('product_id') == product_id:
if remove:
order['cart'].remove(item)
else:
if exact_qty is not None:
item['quantity'] = int(exact_qty)
else:
item['quantity'] += change
if item['quantity'] <= 0:
order['cart'].remove(item)
break
cart_total = sum(float(i['price']) * int(i['quantity']) for i in order['cart'])
discount = order.get('discount', 0)
order['total_price'] = max(0, cart_total - discount)
save_data(data)
return jsonify({"success": True, "total_price": order['total_price']})
@app.route('/admin', methods=['GET', 'POST'])
def admin():
data = load_data()
products = data.get('products', [])
categories = data.get('categories', [])
orders = data.get('orders', {})
if request.method == 'POST':
action = request.form.get('action')
if action == 'apply_discount':
order_id = request.form.get('order_id')
discount_val = float(request.form.get('discount_amount', 0))
if order_id in orders:
order = orders[order_id]
order['discount'] = discount_val
cart_total = sum(float(i['price']) * int(i['quantity']) for i in order['cart'])
order['total_price'] = max(0, cart_total - discount_val)
data['orders'] = orders
save_data(data)
elif action == 'add_category':
cat_name = request.form.get('category_name', '').strip()
if cat_name and cat_name not in categories:
categories.append(cat_name)
data['categories'] = categories
save_data(data)
elif action == 'delete_category':
cat_name = request.form.get('category_name')
if cat_name in categories:
categories.remove(cat_name)
data['products'] = [p for p in products if p.get('category') != cat_name]
data['categories'] = categories
save_data(data)
elif action == 'add_product':
name = request.form.get('name', '').strip()
price = float(request.form.get('price', 0))
pieces_per_box = int(request.form.get('pieces_per_box', 1))
description = request.form.get('description', '').strip()
category = request.form.get('category')
uploaded_photos = request.files.getlist('photos')[:10]
photos_list = []
if uploaded_photos and HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
for photo in uploaded_photos:
if photo and photo.filename:
ext = os.path.splitext(photo.filename)[1].lower()
if ext not in ['.jpg', '.jpeg', '.png', '.webp', '.gif']:
continue
photo_filename = f"{uuid4().hex}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename)
photo.save(temp_path)
try:
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"photos/{photo_filename}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE
)
photos_list.append(photo_filename)
except Exception:
pass
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
new_product = {
'product_id': uuid4().hex,
'name': name,
'price': price,
'pieces_per_box': pieces_per_box,
'description': description,
'category': category,
'photos': photos_list
}
products.append(new_product)
data['products'] = products
save_data(data)
elif action == 'edit_product':
pid = request.form.get('product_id')
name = request.form.get('name', '').strip()
price = float(request.form.get('price', 0))
pieces_per_box = int(request.form.get('pieces_per_box', 1))
description = request.form.get('description', '').strip()
uploaded_photos = request.files.getlist('photos')[:10]
photos_list = []
if uploaded_photos and uploaded_photos[0].filename and HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
for photo in uploaded_photos:
if photo and photo.filename:
ext = os.path.splitext(photo.filename)[1].lower()
if ext not in ['.jpg', '.jpeg', '.png', '.webp', '.gif']:
continue
photo_filename = f"{uuid4().hex}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename)
photo.save(temp_path)
try:
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"photos/{photo_filename}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE
)
photos_list.append(photo_filename)
except Exception:
pass
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
for p in products:
if p.get('product_id') == pid:
p['name'] = name
p['price'] = price
p['pieces_per_box'] = pieces_per_box
p['description'] = description
if photos_list:
p['photos'] = photos_list
break
data['products'] = products
save_data(data)
elif action == 'delete_product':
pid = request.form.get('product_id')
data['products'] = [p for p in products if p.get('product_id') != pid]
save_data(data)
return redirect(url_for('admin'))
return render_template_string(
ADMIN_TEMPLATE,
products=products,
categories=categories,
orders=orders,
repo_id=REPO_ID,
currency_code=CURRENCY_CODE
)
@app.route('/force_upload', methods=['POST'])
def force_upload():
upload_db_to_hf()
return redirect(url_for('admin'))
@app.route('/force_download', methods=['POST'])
def force_download():
download_db_from_hf()
return redirect(url_for('admin'))
if __name__ == '__main__':
download_db_from_hf()
load_data()
if HF_TOKEN_WRITE:
threading.Thread(target=periodic_backup, daemon=True).start()
port = int(os.environ.get('PORT', 7860))
app.run(host='0.0.0.0', port=port)