| 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) |