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