Tech / app.py
Kgshop's picture
Update app.py
0a8408c verified
raw
history blame
83.4 kB
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/1776929812446-019db944-b5db-7524-8f44-73942d70a0f8.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)