|
|
from flask import Flask, render_template_string, request, redirect, url_for, send_file, jsonify |
|
|
import json |
|
|
import os |
|
|
import logging |
|
|
import threading |
|
|
import time |
|
|
from datetime import datetime, timezone, timedelta |
|
|
from huggingface_hub import HfApi, hf_hub_download |
|
|
from huggingface_hub.utils import RepositoryNotFoundError |
|
|
from werkzeug.utils import secure_filename |
|
|
import pandas as pd |
|
|
from io import BytesIO |
|
|
import requests |
|
|
from dotenv import load_dotenv |
|
|
|
|
|
load_dotenv() |
|
|
|
|
|
app = Flask(__name__) |
|
|
DATA_FILE = 'data_optomshop.json' |
|
|
ORDERS_FILE = 'orders_optomshop.json' |
|
|
|
|
|
REPO_ID = "Kgshop/optomshop" |
|
|
HF_TOKEN_WRITE = os.getenv("HF_TOKEN") |
|
|
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") |
|
|
|
|
|
WHATSAPP_NUMBER = "996703103679" |
|
|
|
|
|
logging.basicConfig(level=logging.DEBUG) |
|
|
|
|
|
BISHKEK_TIMEZONE = timezone(timedelta(hours=6)) |
|
|
|
|
|
def load_data(): |
|
|
try: |
|
|
download_db_from_hf() |
|
|
with open(DATA_FILE, 'r', encoding='utf-8') as f: |
|
|
data = json.load(f) |
|
|
if not isinstance(data, dict) or 'products' not in data or 'categories' not in data: |
|
|
return {'products': [], 'categories': []} |
|
|
if data['categories'] and isinstance(data['categories'][0], str): |
|
|
data['categories'] = [{'name': cat, 'subcategories': []} for cat in data['categories']] |
|
|
return data |
|
|
except (FileNotFoundError, json.JSONDecodeError, RepositoryNotFoundError) as e: |
|
|
logging.warning(f"Problem loading data (using empty values): {e}") |
|
|
return {'products': [], 'categories': []} |
|
|
except Exception as e: |
|
|
logging.error(f"Unexpected error loading data: {e}") |
|
|
return {'products': [], 'categories': []} |
|
|
|
|
|
def save_data(data): |
|
|
try: |
|
|
with open(DATA_FILE, 'w', encoding='utf-8') as f: |
|
|
json.dump(data, f, ensure_ascii=False, indent=4) |
|
|
upload_db_to_hf() |
|
|
except Exception as e: |
|
|
logging.error(f"Error saving data: {e}") |
|
|
|
|
|
def load_orders(): |
|
|
try: |
|
|
if not os.path.exists(ORDERS_FILE): |
|
|
return [] |
|
|
with open(ORDERS_FILE, 'r', encoding='utf-8') as f: |
|
|
return json.load(f) |
|
|
except (FileNotFoundError, json.JSONDecodeError) as e: |
|
|
logging.warning(f"Problem loading orders: {e}") |
|
|
return [] |
|
|
except Exception as e: |
|
|
logging.error(f"Error loading orders: {e}") |
|
|
return [] |
|
|
|
|
|
def save_orders(orders): |
|
|
try: |
|
|
with open(ORDERS_FILE, 'w', encoding='utf-8') as f: |
|
|
json.dump(orders, f, ensure_ascii=False, indent=4) |
|
|
except Exception as e: |
|
|
logging.error(f"Error saving orders: {e}") |
|
|
|
|
|
def upload_db_to_hf(): |
|
|
try: |
|
|
if not HF_TOKEN_WRITE: |
|
|
raise ValueError("HF_TOKEN_WRITE not set.") |
|
|
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"Automatic backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" |
|
|
) |
|
|
logging.info("Database uploaded to HF Hub.") |
|
|
except Exception as e: |
|
|
logging.error(f"Error uploading to HF Hub: {e}") |
|
|
|
|
|
def download_db_from_hf(): |
|
|
try: |
|
|
if not HF_TOKEN_READ: |
|
|
raise ValueError("HF_TOKEN_READ not set") |
|
|
hf_hub_download( |
|
|
repo_id=REPO_ID, |
|
|
filename=DATA_FILE, |
|
|
repo_type="dataset", |
|
|
token=HF_TOKEN_READ, |
|
|
local_dir=".", |
|
|
local_dir_use_symlinks=False |
|
|
) |
|
|
logging.info("Database downloaded from HF Hub.") |
|
|
except RepositoryNotFoundError as e: |
|
|
logging.error(f"Repository not found: {e}") |
|
|
raise |
|
|
except Exception as e: |
|
|
logging.error(f"Error downloading from HF Hub: {e}") |
|
|
raise |
|
|
|
|
|
def periodic_backup(): |
|
|
while True: |
|
|
upload_db_to_hf() |
|
|
time.sleep(800) |
|
|
|
|
|
@app.route('/') |
|
|
def index(): |
|
|
return redirect(url_for('catalog')) |
|
|
|
|
|
@app.route('/catalog') |
|
|
def catalog(): |
|
|
data = load_data() |
|
|
return render_template_string(get_catalog_template(), products=data['products'], categories=data['categories'], repo_id=REPO_ID, current_category="all") |
|
|
|
|
|
@app.route('/catalog/<category>') |
|
|
def category_catalog(category): |
|
|
data = load_data() |
|
|
filtered_products = [] |
|
|
if ':' in category: |
|
|
main_category, subcategory = category.split(':', 1) |
|
|
main_category = main_category.strip().lower() |
|
|
subcategory = subcategory.strip().lower() |
|
|
filtered_products = [ |
|
|
p for p in data['products'] |
|
|
if str(p.get('category', '')).strip().lower() == main_category and str(p.get('subcategory', '')).strip().lower() == subcategory |
|
|
] |
|
|
else: |
|
|
category = category.strip().lower() |
|
|
filtered_products = [ |
|
|
p for p in data['products'] |
|
|
if str(p.get('category', '')).strip().lower() == category |
|
|
] |
|
|
return render_template_string(get_catalog_template(), products=filtered_products, categories=data['categories'], repo_id=REPO_ID, current_category=category) |
|
|
|
|
|
def get_catalog_template(): |
|
|
return ''' |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Optomshop - мужская одежда, футболки оптом</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=Poppins:wght@300;400;600&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
body { |
|
|
font-family: 'Poppins', sans-serif; |
|
|
background: linear-gradient(135deg, #f0f2f5, #e9ecef); |
|
|
color: #2d3748; |
|
|
line-height: 1.6; |
|
|
margin: 0; |
|
|
} |
|
|
.container { |
|
|
max-width: 1300px; |
|
|
margin: 0 auto; |
|
|
padding: 20px; |
|
|
padding-top: 0; |
|
|
} |
|
|
.header { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
padding: 30px 0; |
|
|
background-image: url('https://huggingface.co/spaces/Kgshop/Optomshop/resolve/main/IMG-20250329-WA0068.jpg'); |
|
|
background-size: 100% 100%; |
|
|
background-position: center; |
|
|
background-repeat: no-repeat; |
|
|
border-bottom: 2px solid #e2e8f0; |
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
height: 200px; |
|
|
} |
|
|
.header::before { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.3), rgba(16, 185, 129, 0.3)); |
|
|
z-index: 1; |
|
|
opacity: 0.5; |
|
|
} |
|
|
.section-title { |
|
|
text-align: center; |
|
|
font-size: 1.2rem; |
|
|
font-weight: 500; |
|
|
color: #2d3748; |
|
|
margin: 15px 0; |
|
|
} |
|
|
.social-icons { |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
gap: 15px; |
|
|
margin: 5px 0; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
.social-icon { |
|
|
font-size: 1.5rem; |
|
|
color: #2d3748; |
|
|
transition: color 0.3s ease; |
|
|
} |
|
|
.social-icon:hover { |
|
|
color: #3b82f6; |
|
|
} |
|
|
hr { |
|
|
border: 0; |
|
|
height: 1px; |
|
|
background: rgba(45, 55, 72, 0.3); |
|
|
margin: 20px 0; |
|
|
} |
|
|
.category-buttons { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(2, 1fr); |
|
|
gap: 10px; |
|
|
padding: 10px; |
|
|
} |
|
|
.category-button { |
|
|
padding: 15px; |
|
|
border: 1px solid #e2e8f0; |
|
|
border-radius: 8px; |
|
|
background-color: #fff; |
|
|
text-align: center; |
|
|
font-size: 1rem; |
|
|
font-weight: 500; |
|
|
cursor: pointer; |
|
|
transition: all 0.3s ease; |
|
|
} |
|
|
.category-button:hover { |
|
|
background-color: #f0f2f5; |
|
|
border-color: #3b82f6; |
|
|
} |
|
|
.subcategory-list { |
|
|
display: none; |
|
|
padding: 10px; |
|
|
background-color: #f9fafb; |
|
|
border-radius: 8px; |
|
|
margin-top: 5px; |
|
|
} |
|
|
.category-button.active + .subcategory-list { |
|
|
display: block; |
|
|
} |
|
|
.subcategory-item { |
|
|
padding: 10px; |
|
|
border-bottom: 1px solid #e2e8f0; |
|
|
cursor: pointer; |
|
|
transition: background-color 0.3s ease; |
|
|
} |
|
|
.subcategory-item:last-child { |
|
|
border-bottom: none; |
|
|
} |
|
|
.subcategory-item:hover { |
|
|
background-color: #e2e8f0; |
|
|
} |
|
|
.products-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(2, minmax(200px, 1fr)); |
|
|
gap: 15px; |
|
|
padding: 10px; |
|
|
} |
|
|
.product { |
|
|
background: #fff; |
|
|
border-radius: 15px; |
|
|
padding: 15px; |
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); |
|
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; |
|
|
overflow: hidden; |
|
|
cursor: pointer; |
|
|
} |
|
|
.product:hover { |
|
|
transform: translateY(-5px) scale(1.02); |
|
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); |
|
|
} |
|
|
.product-image { |
|
|
width: 100%; |
|
|
aspect-ratio: 1; |
|
|
background-color: #fff; |
|
|
border-radius: 10px; |
|
|
overflow: hidden; |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
} |
|
|
.product-image img { |
|
|
max-width: 100%; |
|
|
max-height: 100%; |
|
|
object-fit: contain; |
|
|
transition: transform 0.3s ease; |
|
|
} |
|
|
.product-image img:hover { |
|
|
transform: scale(1.1); |
|
|
} |
|
|
.product h2 { |
|
|
font-size: 1rem; |
|
|
font-weight: 600; |
|
|
margin: 10px 0; |
|
|
text-align: center; |
|
|
white-space: nowrap; |
|
|
overflow: hidden; |
|
|
text-overflow: ellipsis; |
|
|
} |
|
|
.product-price { |
|
|
font-size: 1.1rem; |
|
|
color: #ef4444; |
|
|
font-weight: 700; |
|
|
text-align: center; |
|
|
margin: 5px 0; |
|
|
} |
|
|
.product-description { |
|
|
font-size: 0.8rem; |
|
|
color: #68096; |
|
|
text-align: center; |
|
|
margin-bottom: 15px; |
|
|
overflow: hidden; |
|
|
text-overflow: ellipsis; |
|
|
white-space: nowrap; |
|
|
} |
|
|
.product-button { |
|
|
display: block; |
|
|
width: 100%; |
|
|
padding: 8px; |
|
|
border: none; |
|
|
border-radius: 8px; |
|
|
background-color: #3b82f6; |
|
|
color: white; |
|
|
font-size: 0.8rem; |
|
|
font-weight: 500; |
|
|
cursor: pointer; |
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
|
|
margin: 5px 0; |
|
|
text-align: center; |
|
|
text-decoration: none; |
|
|
} |
|
|
.product-button:hover { |
|
|
background-color: #2563eb; |
|
|
box-shadow: 0 4px 15px rgba(37, 99, 235, 0.4); |
|
|
transform: translateY(-2px); |
|
|
} |
|
|
.add-to-cart { |
|
|
background-color: #10b981; |
|
|
} |
|
|
.add-to-cart:hover { |
|
|
background-color: #059669; |
|
|
box-shadow: 0 4px 15px rgba(5, 150, 105, 0.4); |
|
|
} |
|
|
#cart-button { |
|
|
position: fixed; |
|
|
bottom: 20px; |
|
|
right: 20px; |
|
|
background-color: #ef4444; |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 50%; |
|
|
width: 50px; |
|
|
height: 50px; |
|
|
font-size: 1.2rem; |
|
|
cursor: pointer; |
|
|
display: none; |
|
|
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4); |
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
|
|
z-index: 1000; |
|
|
} |
|
|
.modal { |
|
|
display: none; |
|
|
position: fixed; |
|
|
z-index: 1001; |
|
|
left: 0; |
|
|
top: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background-color: rgba(0,0,0,0.5); |
|
|
} |
|
|
.modal-content { |
|
|
background-color: #ffffff; |
|
|
margin: 15% auto; |
|
|
padding: 20px; |
|
|
border-radius: 15px; |
|
|
width: 90%; |
|
|
max-width: 500px; |
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2); |
|
|
animation: slideIn 0.3s ease-out; |
|
|
overflow-y: auto; |
|
|
max-height: 70vh; |
|
|
} |
|
|
@keyframes slideIn { |
|
|
from { transform: translateY(-50px); opacity: 0; } |
|
|
to { transform: translateY(0); opacity: 1; } |
|
|
} |
|
|
.close { |
|
|
float: right; |
|
|
font-size: 1.5rem; |
|
|
color: #718096; |
|
|
cursor: pointer; |
|
|
transition: color 0.3s; |
|
|
} |
|
|
.close:hover { |
|
|
color: #2d3748; |
|
|
} |
|
|
.cart-item { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
padding: 15px 0; |
|
|
border-bottom: 1px solid #e2e8f0; |
|
|
} |
|
|
.cart-item img { |
|
|
width: 50px; |
|
|
height: 50px; |
|
|
object-fit: contain; |
|
|
border-radius: 8px; |
|
|
margin-right: 15px; |
|
|
} |
|
|
.quantity-input, .color-select { |
|
|
width: 100%; |
|
|
max-width: 150px; |
|
|
padding: 8px; |
|
|
border: 1px solid #e2e8f0; |
|
|
border-radius: 8px; |
|
|
font-size: 1rem; |
|
|
margin: 5px 0; |
|
|
} |
|
|
.clear-cart { |
|
|
background-color: #ef4444; |
|
|
} |
|
|
.clear-cart:hover { |
|
|
background-color: #dc2626; |
|
|
box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4); |
|
|
} |
|
|
.order-button { |
|
|
background-color: #10b981; |
|
|
} |
|
|
.order-button:hover { |
|
|
background-color: #059669; |
|
|
box-shadow: 0 4px 15px rgba(5, 150, 105, 0.4); |
|
|
} |
|
|
.remove-item-btn { |
|
|
background-color: #ef4444; |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 5px; |
|
|
padding: 5px 10px; |
|
|
cursor: pointer; |
|
|
font-size: 0.8rem; |
|
|
transition: background-color 0.3s; |
|
|
} |
|
|
.remove-item-btn:hover { |
|
|
background-color: #dc2626; |
|
|
} |
|
|
.social-button { |
|
|
padding: 10px 20px; |
|
|
border: none; |
|
|
border-radius: 8px; |
|
|
color: white; |
|
|
font-size: 1rem; |
|
|
font-weight: 500; |
|
|
cursor: pointer; |
|
|
transition: all 0.3s ease; |
|
|
text-decoration: none; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 5px; |
|
|
width: 100%; |
|
|
justify-content: center; |
|
|
margin: 5px 0; |
|
|
} |
|
|
.social-button:hover { |
|
|
transform: translateY(-2px); |
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); |
|
|
} |
|
|
.call-button { background-color: #4CAF50; } |
|
|
.whatsapp-button { background-color: #25D366; } |
|
|
.instagram-button { background-color: #E1306C; } |
|
|
.telegram-button { background-color: #0088cc; } |
|
|
.whatsapp-group-button { background-color: #25D366; } |
|
|
.location-button { background-color: #FF9800; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<div class="header"></div> |
|
|
<div class="section-title">Контакты</div> |
|
|
<div class="social-icons"> |
|
|
<a href="tel:+996703103679" class="social-icon"><i class="fas fa-phone"></i></a> |
|
|
<a href="https://instagram.com/optomshop.kg" target="_blank" class="social-icon"><i class="fab fa-instagram"></i></a> |
|
|
<a href="https://t.me/+5b2MhBek3SU5YjA6" target="_blank" class="social-icon"><i class="fab fa-telegram"></i></a> |
|
|
<a href="https://api.whatsapp.com/send?phone=996703103679" target="_blank" class="social-icon"><i class="fab fa-whatsapp"></i></a> |
|
|
<a href="https://2gis.kg/bishkek/geo/70030076288068720/74.616388,42.940167" target="_blank" class="social-icon"><i class="fas fa-map-marker-alt"></i></a> |
|
|
</div> |
|
|
<hr> |
|
|
<div class="section-title">Группы</div> |
|
|
<div class="social-icons"> |
|
|
<a href="https://chat.whatsapp.com/LnY1q03GxTo43ccuNoy8MB" target="_blank" class="social-icon"><i class="fab fa-whatsapp"></i></a> |
|
|
<a href="https://t.me/+5b2MhBek3SU5YjA6" target="_blank" class="social-icon"><i class="fab fa-telegram"></i></a> |
|
|
</div> |
|
|
<hr> |
|
|
<div class="category-buttons"> |
|
|
<div class="category-button" onclick="window.location.href='/catalog'">Все категории</div> |
|
|
{% for category in categories %} |
|
|
<div class="category-button" onclick="toggleSubcategories(this)"> |
|
|
{{ category['name'] }} |
|
|
</div> |
|
|
{% if category['subcategories'] %} |
|
|
<div class="subcategory-list"> |
|
|
{% for subcat in category['subcategories'] %} |
|
|
<div class="subcategory-item" onclick="window.location.href='/catalog/{{ category['name'] }}:{{ subcat }}'">{{ subcat }}</div> |
|
|
{% endfor %} |
|
|
<div class="subcategory-item" onclick="window.location.href='/catalog/{{ category['name'] }}'">Все в {{ category['name'] }}</div> |
|
|
</div> |
|
|
{% endif %} |
|
|
{% endfor %} |
|
|
</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', 'Без категории') }}" |
|
|
onclick="window.location.href='/product/{{ loop.index0 }}'"> |
|
|
{% if product.get('photos') and product['photos']|length > 0 %} |
|
|
<div class="product-image"> |
|
|
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" |
|
|
alt="{{ product['name'] }}" |
|
|
loading="lazy"> |
|
|
</div> |
|
|
{% endif %} |
|
|
<h2>{{ product['name'] }}</h2> |
|
|
<div class="product-price">{{ product['price']|int }} сом</div> |
|
|
<p class="product-description">{{ product['description'][:50] }}{% if product['description']|length > 50 %}...{% endif %}</p> |
|
|
<button class="product-button add-to-cart" onclick="event.stopPropagation(); openQuantityModal({{ loop.index0 }})">В корзину</button> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
</div> |
|
|
<div id="quantityModal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<span class="close" onclick="closeModal('quantityModal')">×</span> |
|
|
<h2>Укажите количество и цвет</h2> |
|
|
<input type="number" id="quantityInput" class="quantity-input" min="1" value="1"> |
|
|
<select id="colorSelect" class="color-select"></select> |
|
|
<button class="product-button" onclick="confirmAddToCart()">Добавить</button> |
|
|
</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; text-align: right;"> |
|
|
<strong>Итого: <span id="cartTotal">0</span> сом</strong> |
|
|
<button class="product-button clear-cart" onclick="clearCart()">Очистить</button> |
|
|
<button class="product-button order-button" onclick="orderViaWhatsApp()">Заказать</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<button id="cart-button" onclick="openCartModal()">🛒</button> |
|
|
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script> |
|
|
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.3/dist/umd/popper.min.js"></script> |
|
|
<script> |
|
|
const products = {{ products|tojson }}; |
|
|
let selectedProductIndex = null; |
|
|
|
|
|
function toggleSubcategories(element) { |
|
|
element.classList.toggle('active'); |
|
|
} |
|
|
|
|
|
function openModal(modalId) { |
|
|
document.getElementById(modalId).style.display = 'block'; |
|
|
} |
|
|
|
|
|
function closeModal(modalId) { |
|
|
document.getElementById(modalId).style.display = 'none'; |
|
|
} |
|
|
|
|
|
function openQuantityModal(index) { |
|
|
selectedProductIndex = index; |
|
|
const product = products[index]; |
|
|
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); |
|
|
} |
|
|
document.getElementById('quantityModal').style.display = 'block'; |
|
|
document.getElementById('quantityInput').value = 1; |
|
|
} |
|
|
|
|
|
function confirmAddToCart() { |
|
|
if (selectedProductIndex === null) return; |
|
|
const quantity = parseInt(document.getElementById('quantityInput').value) || 1; |
|
|
const color = document.getElementById('colorSelect').value; |
|
|
if (quantity <= 0) { |
|
|
alert("Укажите количество больше 0"); |
|
|
return; |
|
|
} |
|
|
let cart = JSON.parse(localStorage.getItem('cart') || '[]'); |
|
|
const product = products[selectedProductIndex]; |
|
|
const cartItemId = `${product.name}-${color}`; |
|
|
const existingItem = cart.find(item => item.id === cartItemId); |
|
|
|
|
|
if (existingItem) { |
|
|
existingItem.quantity += quantity; |
|
|
} else { |
|
|
cart.push({ |
|
|
id: cartItemId, |
|
|
name: product.name, |
|
|
price: product.price, |
|
|
photo: product.photos && product.photos.length > 0 ? product.photos[0] : '', |
|
|
quantity: quantity, |
|
|
color: color |
|
|
}); |
|
|
} |
|
|
|
|
|
localStorage.setItem('cart', JSON.stringify(cart)); |
|
|
closeModal('quantityModal'); |
|
|
updateCartButton(); |
|
|
} |
|
|
|
|
|
function updateCartButton() { |
|
|
const cart = JSON.parse(localStorage.getItem('cart') || '[]'); |
|
|
document.getElementById('cart-button').style.display = cart.length > 0 ? 'block' : 'none'; |
|
|
} |
|
|
|
|
|
function openCartModal() { |
|
|
const cart = JSON.parse(localStorage.getItem('cart') || '[]'); |
|
|
const cartContent = document.getElementById('cartContent'); |
|
|
let total = 0; |
|
|
|
|
|
cartContent.innerHTML = cart.length === 0 ? '<p>Корзина пуста</p>' : cart.map(item => { |
|
|
const itemTotal = item.price * item.quantity; |
|
|
total += itemTotal; |
|
|
return ` |
|
|
<div class="cart-item"> |
|
|
<div style="display: flex; align-items: center;"> |
|
|
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/${item.photo}" alt="${item.name}"> |
|
|
<div> |
|
|
<strong>${item.name}</strong> |
|
|
<p>${item.price} сом × ${item.quantity} (Цвет: ${item.color})</p> |
|
|
</div> |
|
|
</div> |
|
|
<div> |
|
|
<span>${itemTotal} сом</span> |
|
|
<button class="remove-item-btn" onclick="removeItem('${item.id}')">Удалить</button> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join(''); |
|
|
|
|
|
document.getElementById('cartTotal').textContent = total; |
|
|
document.getElementById('cartModal').style.display = 'block'; |
|
|
} |
|
|
|
|
|
function removeItem(itemId) { |
|
|
let cart = JSON.parse(localStorage.getItem('cart') || '[]'); |
|
|
cart = cart.filter(item => item.id !== itemId); |
|
|
localStorage.setItem('cart', JSON.stringify(cart)); |
|
|
openCartModal(); |
|
|
updateCartButton(); |
|
|
} |
|
|
|
|
|
function orderViaWhatsApp() { |
|
|
const cart = JSON.parse(localStorage.getItem('cart') || '[]'); |
|
|
if (cart.length === 0) { |
|
|
alert("Корзина пуста!"); |
|
|
return; |
|
|
} |
|
|
|
|
|
let total = 0; |
|
|
let orderText = "Заказ:%0A"; |
|
|
cart.forEach((item, index) => { |
|
|
const itemTotal = item.price * item.quantity; |
|
|
total += itemTotal; |
|
|
orderText += `${index + 1}. ${item.name} - ${item.price} сом × ${item.quantity} (Цвет: ${item.color})%0A`; |
|
|
}); |
|
|
orderText += `Итого: ${total} сом`; |
|
|
|
|
|
window.open(`https://api.whatsapp.com/send?phone=996703103679&text=${orderText}`, '_blank'); |
|
|
saveOrder(cart, total); |
|
|
} |
|
|
|
|
|
function saveOrder(cart, total) { |
|
|
const order = { |
|
|
timestamp: new Date().toISOString(), |
|
|
items: cart, |
|
|
total: total |
|
|
}; |
|
|
|
|
|
fetch('/save_order', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
body: JSON.stringify(order), |
|
|
}) |
|
|
.then(response => response.json()) |
|
|
.then(data => { |
|
|
if (data.success) { |
|
|
console.log('Заказ сохранен:', data); |
|
|
localStorage.removeItem('cart'); |
|
|
updateCartButton(); |
|
|
closeModal('cartModal'); |
|
|
} else { |
|
|
console.error('Ошибка при сохранении заказа:', data.error); |
|
|
} |
|
|
}) |
|
|
.catch(error => { |
|
|
console.error('Ошибка при отправке запроса:', error); |
|
|
}); |
|
|
} |
|
|
|
|
|
function clearCart() { |
|
|
localStorage.removeItem('cart'); |
|
|
closeModal('cartModal'); |
|
|
updateCartButton(); |
|
|
} |
|
|
|
|
|
window.onclick = function(event) { |
|
|
if (event.target.className === 'modal') event.target.style.display = "none"; |
|
|
} |
|
|
|
|
|
updateCartButton(); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
''' |
|
|
|
|
|
@app.route('/product/<int:index>') |
|
|
def product_detail(index): |
|
|
data = load_data() |
|
|
products = data['products'] |
|
|
try: |
|
|
product = products[index] |
|
|
except IndexError: |
|
|
return "Продукт не найден", 404 |
|
|
|
|
|
detail_html = ''' |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>{{ product['name'] }} - Optomshop</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=Poppins:wght@300;400;600&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
body { |
|
|
font-family: 'Poppins', sans-serif; |
|
|
background: linear-gradient(135deg, #f0f2f5, #e9ecef); |
|
|
color: #2d3748; |
|
|
line-height: 1.6; |
|
|
margin: 0; |
|
|
} |
|
|
.container { |
|
|
max-width: 1300px; |
|
|
margin: 0 auto; |
|
|
padding: 20px; |
|
|
} |
|
|
.product-details { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
gap: 20px; |
|
|
padding: 20px; |
|
|
background: #fff; |
|
|
border-radius: 15px; |
|
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1); |
|
|
} |
|
|
.product-details h1 { |
|
|
font-size: 1.8rem; |
|
|
font-weight: 600; |
|
|
margin-bottom: 10px; |
|
|
} |
|
|
.product-images { |
|
|
width: 100%; |
|
|
max-width: 300px; |
|
|
} |
|
|
.main-image { |
|
|
width: 100%; |
|
|
aspect-ratio: 1; |
|
|
background-color: #fff; |
|
|
border-radius: 10px; |
|
|
overflow: hidden; |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
margin-bottom: 10px; |
|
|
} |
|
|
.main-image img { |
|
|
max-width: 100%; |
|
|
max-height: 100%; |
|
|
object-fit: contain; |
|
|
} |
|
|
.thumbnails { |
|
|
display: flex; |
|
|
flex-wrap: wrap; |
|
|
justify-content: center; |
|
|
gap: 10px; |
|
|
} |
|
|
.thumbnail { |
|
|
width: 60px; |
|
|
height: 60px; |
|
|
object-fit: cover; |
|
|
border-radius: 8px; |
|
|
cursor: pointer; |
|
|
transition: transform 0.2s; |
|
|
} |
|
|
.thumbnail:hover { |
|
|
transform: scale(1.1); |
|
|
} |
|
|
.product-info { |
|
|
width: 100%; |
|
|
text-align: center; |
|
|
} |
|
|
.product-info p { |
|
|
margin: 8px 0; |
|
|
} |
|
|
.back-button { |
|
|
display: inline-block; |
|
|
padding: 10px 20px; |
|
|
background-color: #3b82f6; |
|
|
color: white; |
|
|
text-decoration: none; |
|
|
border-radius: 8px; |
|
|
margin-top: 20px; |
|
|
transition: background-color 0.3s; |
|
|
} |
|
|
.back-button:hover { |
|
|
background-color: #2563eb; |
|
|
} |
|
|
#lightbox { |
|
|
display: none; |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background: rgba(0, 0, 0, 0.8); |
|
|
z-index: 1000; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
} |
|
|
.lightbox-slider { |
|
|
position: relative; |
|
|
width: 90%; |
|
|
max-width: 500px; |
|
|
overflow: hidden; |
|
|
} |
|
|
.lightbox-images { |
|
|
display: flex; |
|
|
transition: transform 0.3s ease; |
|
|
} |
|
|
.lightbox-img { |
|
|
width: 100%; |
|
|
max-height: 80vh; |
|
|
object-fit: contain; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
#lightbox .close { |
|
|
position: absolute; |
|
|
top: 20px; |
|
|
right: 30px; |
|
|
color: white; |
|
|
font-size: 3rem; |
|
|
cursor: pointer; |
|
|
transition: color 0.3s; |
|
|
} |
|
|
#lightbox .close:hover { |
|
|
color: #ccc; |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<div class="product-details"> |
|
|
<h1>{{ product['name'] }}</h1> |
|
|
<div class="product-images"> |
|
|
<div class="main-image"> |
|
|
{% if product.get('photos') and product['photos']|length > 0 %} |
|
|
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" |
|
|
alt="{{ product['name'] }}"> |
|
|
{% else %} |
|
|
<img src="https://via.placeholder.com/150" alt="No Image"> |
|
|
{% endif %} |
|
|
</div> |
|
|
<div class="thumbnails"> |
|
|
{% if product.get('photos') %} |
|
|
{% for photo in product['photos'] %} |
|
|
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}" |
|
|
alt="{{ product['name'] }}" |
|
|
class="thumbnail" |
|
|
onclick="showLightbox({{ loop.index0 }})"> |
|
|
{% endfor %} |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
<div class="product-info"> |
|
|
<p><strong>Артикул:</strong> {{ product.get('article', 'Не указан') }}</p> |
|
|
<p><strong>Доступные цвета:</strong> {{ product.get('colors', ['Нет цветов'])|join(', ') }}</p> |
|
|
<p><strong>Размеры:</strong> {{ product.get('size', 'Не указан') }}</p> |
|
|
<p><strong>Цена:</strong> {{ product['price']|int }} сом</p> |
|
|
<p><strong>Описание:</strong> {{ product['description'] }}</p> |
|
|
<a href="/catalog" class="back-button">Назад к каталогу</a> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div id="lightbox" onclick="closeLightbox(event)"> |
|
|
<span class="close" onclick="closeLightbox(event)">×</span> |
|
|
<div class="lightbox-slider"> |
|
|
<div class="lightbox-images" id="lightboxImages"> |
|
|
{% if product.get('photos') %} |
|
|
{% for photo in product['photos'] %} |
|
|
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}" |
|
|
class="lightbox-img" |
|
|
alt="{{ product['name'] }}"> |
|
|
{% endfor %} |
|
|
{% else %} |
|
|
<img src="https://via.placeholder.com/150" class="lightbox-img" alt="No Image"> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<script> |
|
|
const photos = [ |
|
|
{% if product.get('photos') %} |
|
|
{% for photo in product['photos'] %} |
|
|
"https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}", |
|
|
{% endfor %} |
|
|
{% else %} |
|
|
"https://via.placeholder.com/150" |
|
|
{% endif %} |
|
|
]; |
|
|
let currentIndex = 0; |
|
|
|
|
|
function showLightbox(index) { |
|
|
currentIndex = index; |
|
|
updateLightbox(); |
|
|
document.getElementById('lightbox').style.display = 'flex'; |
|
|
} |
|
|
|
|
|
function closeLightbox(event) { |
|
|
if (event.target.id === 'lightbox' || event.target.className === 'close') { |
|
|
document.getElementById('lightbox').style.display = 'none'; |
|
|
} |
|
|
} |
|
|
|
|
|
function updateLightbox() { |
|
|
const lightboxImages = document.getElementById('lightboxImages'); |
|
|
const offset = -currentIndex * 100; |
|
|
lightboxImages.style.transform = `translateX(${offset}%)`; |
|
|
} |
|
|
|
|
|
// Swipe functionality for lightbox |
|
|
if (photos.length > 1) { |
|
|
const lightboxSlider = document.querySelector('.lightbox-slider'); |
|
|
let startX = 0; |
|
|
let isDragging = false; |
|
|
|
|
|
lightboxSlider.addEventListener('touchstart', (e) => { |
|
|
startX = e.touches[0].clientX; |
|
|
isDragging = true; |
|
|
}); |
|
|
|
|
|
lightboxSlider.addEventListener('touchmove', (e) => { |
|
|
if (!isDragging) return; |
|
|
const currentX = e.touches[0].clientX; |
|
|
const diff = currentX - startX; |
|
|
if (Math.abs(diff) > 50) { |
|
|
e.preventDefault(); |
|
|
if (diff > 0 && currentIndex > 0) { |
|
|
currentIndex--; |
|
|
} else if (diff < 0 && currentIndex < photos.length - 1) { |
|
|
currentIndex++; |
|
|
} |
|
|
updateLightbox(); |
|
|
isDragging = false; |
|
|
} |
|
|
}); |
|
|
|
|
|
lightboxSlider.addEventListener('touchend', () => { |
|
|
isDragging = false; |
|
|
}); |
|
|
} |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
''' |
|
|
return render_template_string(detail_html, product=product, repo_id=REPO_ID) |
|
|
|
|
|
@app.route('/save_order', methods=['POST']) |
|
|
def save_order_route(): |
|
|
try: |
|
|
order = request.get_json() |
|
|
orders = load_orders() |
|
|
orders.append(order) |
|
|
save_orders(orders) |
|
|
return jsonify({'success': True}) |
|
|
except Exception as e: |
|
|
logging.error(f"Error saving order: {e}") |
|
|
return jsonify({'success': False, 'error': str(e)}) |
|
|
|
|
|
@app.route('/admin', methods=['GET', 'POST']) |
|
|
def admin(): |
|
|
data = load_data() |
|
|
products = data['products'] |
|
|
categories = data['categories'] |
|
|
orders = load_orders() |
|
|
|
|
|
if request.method == 'POST': |
|
|
action = request.form.get('action') |
|
|
|
|
|
if action == 'add_category': |
|
|
category_name = request.form.get('category_name').strip().lower() |
|
|
if category_name and not any(c['name'].lower() == category_name for c in categories): |
|
|
categories.append({'name': category_name, 'subcategories': []}) |
|
|
save_data(data) |
|
|
return redirect(url_for('admin')) |
|
|
return "Ошибка: Категория уже существует или не указано название", 400 |
|
|
|
|
|
elif action == 'add_subcategory': |
|
|
category_index = int(request.form.get('category_index')) |
|
|
subcategory_name = request.form.get('subcategory_name').strip().lower() |
|
|
if subcategory_name and subcategory_name not in [sub.lower() for sub in categories[category_index]['subcategories']]: |
|
|
categories[category_index]['subcategories'].append(subcategory_name) |
|
|
save_data(data) |
|
|
return redirect(url_for('admin')) |
|
|
return "Ошибка: Подкатегория уже существует или не указано название", 400 |
|
|
|
|
|
elif action == 'delete_category': |
|
|
category_index = int(request.form.get('category_index')) |
|
|
deleted_category = categories.pop(category_index) |
|
|
category_name = deleted_category['name'].lower() |
|
|
for product in products: |
|
|
if product.get('category', '').lower() == category_name: |
|
|
if 'category' in product: |
|
|
del product['category'] |
|
|
if 'subcategory' in product: |
|
|
del product['subcategory'] |
|
|
save_data(data) |
|
|
return redirect(url_for('admin')) |
|
|
|
|
|
elif action == 'delete_subcategory': |
|
|
category_index = int(request.form.get('category_index')) |
|
|
subcategory_index = int(request.form.get('subcategory_index')) |
|
|
deleted_subcategory = categories[category_index]['subcategories'].pop(subcategory_index).lower() |
|
|
for product in products: |
|
|
if product.get('category', '').lower() == categories[category_index]['name'].lower() and product.get('subcategory', '').lower() == deleted_subcategory: |
|
|
if 'subcategory' in product: |
|
|
del product['subcategory'] |
|
|
save_data(data) |
|
|
return redirect(url_for('admin')) |
|
|
|
|
|
elif action == 'add': |
|
|
name = request.form.get('name') |
|
|
price = request.form.get('price') |
|
|
description = request.form.get('description') |
|
|
size = request.form.get('size') |
|
|
article = request.form.get('article') |
|
|
category = request.form.get('category').strip().lower() if request.form.get('category') else None |
|
|
subcategory = request.form.get('subcategory').strip().lower() if request.form.get('subcategory') else None |
|
|
photos_files = request.files.getlist('photos') |
|
|
colors = request.form.getlist('colors') |
|
|
photos_list = [] |
|
|
|
|
|
if photos_files: |
|
|
for photo in photos_files[:10]: |
|
|
if photo and photo.filename: |
|
|
photo_filename = secure_filename(photo.filename) |
|
|
if not photo_filename: |
|
|
return "Ошибка: Недопустимое имя файла", 400 |
|
|
uploads_dir = 'uploads' |
|
|
os.makedirs(uploads_dir, exist_ok=True) |
|
|
temp_path = os.path.join(uploads_dir, photo_filename) |
|
|
photo.save(temp_path) |
|
|
|
|
|
if not HF_TOKEN_WRITE: |
|
|
return "Ошибка: HF_TOKEN_WRITE не установлен", 500 |
|
|
try: |
|
|
api = HfApi() |
|
|
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, |
|
|
commit_message=f"Добавлено фото: {name}" |
|
|
) |
|
|
photos_list.append(photo_filename) |
|
|
except Exception as e: |
|
|
logging.error(f"Ошибка при загрузке фото: {e}") |
|
|
return f"Ошибка при загрузке фото: {e}", 500 |
|
|
finally: |
|
|
if os.path.exists(temp_path): |
|
|
os.remove(temp_path) |
|
|
|
|
|
if not name or not price or not description: |
|
|
return "Ошибка: Заполните все поля", 400 |
|
|
|
|
|
try: |
|
|
price = int(float(price.replace(',', '.'))) |
|
|
except ValueError: |
|
|
return "Ошибка: Неверный формат цены", 400 |
|
|
|
|
|
new_product = { |
|
|
'name': name, |
|
|
'price': price, |
|
|
'description': description, |
|
|
'size': size if size else 'Не указан', |
|
|
'article': article if article else 'Не указан', |
|
|
'category': category if category else None, |
|
|
'subcategory': subcategory if subcategory and category else None, |
|
|
'photos': photos_list, |
|
|
'colors': colors if colors else [] |
|
|
} |
|
|
products.append(new_product) |
|
|
save_data(data) |
|
|
return redirect(url_for('admin')) |
|
|
|
|
|
elif action == 'edit': |
|
|
index = int(request.form.get('index')) |
|
|
name = request.form.get('name') |
|
|
price = request.form.get('price') |
|
|
description = request.form.get('description') |
|
|
size = request.form.get('size') |
|
|
article = request.form.get('article') |
|
|
category = request.form.get('category').strip().lower() if request.form.get('category') else None |
|
|
subcategory = request.form.get('subcategory').strip().lower() if request.form.get('subcategory') else None |
|
|
photos_files = request.files.getlist('photos') |
|
|
colors = request.form.getlist('colors') |
|
|
|
|
|
if photos_files and any(photo.filename for photo in photos_files): |
|
|
new_photos_list = [] |
|
|
for photo in photos_files[:10]: |
|
|
if photo and photo.filename: |
|
|
photo_filename = secure_filename(photo.filename) |
|
|
if not photo_filename: |
|
|
return "Ошибка: Недопустимое имя файла", 400 |
|
|
uploads_dir = 'uploads' |
|
|
os.makedirs(uploads_dir, exist_ok=True) |
|
|
temp_path = os.path.join(uploads_dir, photo_filename) |
|
|
photo.save(temp_path) |
|
|
|
|
|
if not HF_TOKEN_WRITE: |
|
|
return "Ошибка: HF_TOKEN_WRITE не установлен", 500 |
|
|
try: |
|
|
api = HfApi() |
|
|
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, |
|
|
commit_message=f"Обновлено фото: {name}" |
|
|
) |
|
|
new_photos_list.append(photo_filename) |
|
|
except Exception as e: |
|
|
logging.error(f"Ошибка при загрузке фото: {e}") |
|
|
return f"Ошибка при загрузке фото: {e}", 500 |
|
|
finally: |
|
|
if os.path.exists(temp_path): |
|
|
os.remove(temp_path) |
|
|
products[index]['photos'] = new_photos_list |
|
|
|
|
|
try: |
|
|
products[index]['price'] = int(float(price.replace(',', '.'))) |
|
|
except ValueError: |
|
|
return "Ошибка: Неверный формат цены", 400 |
|
|
|
|
|
products[index]['name'] = name |
|
|
products[index]['description'] = description |
|
|
products[index]['size'] = size if size else 'Не указан' |
|
|
products[index]['article'] = article if article else 'Не указан' |
|
|
products[index]['category'] = category if category else None |
|
|
products[index]['subcategory'] = subcategory if subcategory and category else None |
|
|
products[index]['colors'] = colors if colors else [] |
|
|
save_data(data) |
|
|
return redirect(url_for('admin')) |
|
|
|
|
|
elif action == 'delete': |
|
|
index = int(request.form.get('index')) |
|
|
del products[index] |
|
|
save_data(data) |
|
|
return redirect(url_for('admin')) |
|
|
|
|
|
elif action == 'download_order': |
|
|
order_index = int(request.form.get('order_index')) |
|
|
order = orders[order_index] |
|
|
|
|
|
sorted_items = sorted(order['items'], key=lambda item: (item['name'], item['color'])) |
|
|
|
|
|
df = pd.DataFrame({ |
|
|
'Фото': [item.get('photo', '') for item in sorted_items], |
|
|
'Наименование': [item['name'] for item in sorted_items], |
|
|
'Количество': [item['quantity'] for item in sorted_items], |
|
|
'Цвет': [item['color'] for item in sorted_items], |
|
|
'Итоговая цена': [item['price'] * item['quantity'] for item in sorted_items] |
|
|
}) |
|
|
|
|
|
total_row = pd.DataFrame({ |
|
|
'Фото': [''], |
|
|
'Наименование': ['Итого'], |
|
|
'Количество': [''], |
|
|
'Цвет': [''], |
|
|
'Итоговая цена': [order['total']] |
|
|
}) |
|
|
df = pd.concat([df, total_row], ignore_index=True) |
|
|
|
|
|
output = BytesIO() |
|
|
writer = pd.ExcelWriter(output, engine='xlsxwriter') |
|
|
df.to_excel(writer, sheet_name='Order', index=False) |
|
|
workbook = writer.book |
|
|
worksheet = writer.sheets['Order'] |
|
|
|
|
|
worksheet.set_column('A:A', 20) |
|
|
worksheet.set_column('B:B', 30) |
|
|
worksheet.set_column('C:C', 15) |
|
|
worksheet.set_column('D:D', 15) |
|
|
worksheet.set_column('E:E', 20) |
|
|
|
|
|
text_format = workbook.add_format({ |
|
|
'border': 1, |
|
|
'valign': 'vcenter', |
|
|
'text_wrap': True |
|
|
}) |
|
|
|
|
|
for i, item in enumerate(sorted_items): |
|
|
worksheet.set_row(i + 1, 100) |
|
|
|
|
|
for col in range(1, 5): |
|
|
worksheet.write(i + 1, col, df.iloc[i, col], text_format) |
|
|
|
|
|
if item.get('photo'): |
|
|
image_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photo']}" |
|
|
try: |
|
|
response = requests.get(image_url, timeout=10) |
|
|
response.raise_for_status() |
|
|
image_data = BytesIO(response.content) |
|
|
if len(image_data.getvalue()) > 0: |
|
|
worksheet.insert_image(i + 1, 0, image_url, { |
|
|
'image_data': image_data, |
|
|
'x_scale': 0.05, |
|
|
'y_scale': 0.05, |
|
|
'object_position': 2, |
|
|
'x_offset': 5, |
|
|
'y_offset': 5 |
|
|
}) |
|
|
else: |
|
|
logging.warning(f"Empty image data for {image_url}") |
|
|
except Exception as e: |
|
|
logging.error(f"Error with image {image_url}: {e}") |
|
|
|
|
|
writer.close() |
|
|
output.seek(0) |
|
|
|
|
|
timestamp = datetime.fromisoformat(order['timestamp'].replace('Z', '+00:00')).strftime('%Y%m%d_%H%M%S') |
|
|
filename = f"order_{timestamp}.xlsx" |
|
|
return send_file( |
|
|
output, |
|
|
as_attachment=True, |
|
|
download_name=filename, |
|
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' |
|
|
) |
|
|
|
|
|
elif action == 'delete_order': |
|
|
order_index = int(request.form.get('order_index')) |
|
|
del orders[order_index] |
|
|
save_orders(orders) |
|
|
return redirect(url_for('admin')) |
|
|
|
|
|
return render_template_string(get_admin_template(), products=products, categories=categories, orders=orders, repo_id=REPO_ID) |
|
|
|
|
|
def get_admin_template(): |
|
|
return ''' |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Админ-панель - Optomshop</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=Poppins:wght@300;400;600&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
body { |
|
|
font-family: 'Poppins', sans-serif; |
|
|
background: linear-gradient(135deg, #f0f2f5, #e9ecef); |
|
|
color: #2d3748; |
|
|
line-height: 1.6; |
|
|
margin: 0; |
|
|
padding: 20px; |
|
|
} |
|
|
.container { |
|
|
max-width: 1200px; |
|
|
margin: 0 auto; |
|
|
} |
|
|
h1, h2 { |
|
|
text-align: center; |
|
|
color: #1a202c; |
|
|
} |
|
|
.form-section { |
|
|
background: #fff; |
|
|
padding: 20px; |
|
|
border-radius: 10px; |
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
.form-section h2 { |
|
|
margin-top: 0; |
|
|
} |
|
|
form { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 15px; |
|
|
} |
|
|
label { |
|
|
font-weight: 500; |
|
|
} |
|
|
input, textarea, select { |
|
|
width: 100%; |
|
|
padding: 10px; |
|
|
border: 1px solid #e2e8f0; |
|
|
border-radius: 5px; |
|
|
font-size: 1rem; |
|
|
} |
|
|
textarea { |
|
|
resize: vertical; |
|
|
} |
|
|
button { |
|
|
padding: 10px; |
|
|
border: none; |
|
|
border-radius: 5px; |
|
|
background-color: #3b82f6; |
|
|
color: white; |
|
|
font-size: 1rem; |
|
|
cursor: pointer; |
|
|
transition: background-color 0.3s; |
|
|
} |
|
|
button:hover { |
|
|
background-color: #2563eb; |
|
|
} |
|
|
.delete-button { |
|
|
background-color: #ef4444; |
|
|
} |
|
|
.delete-button:hover { |
|
|
background-color: #dc2626; |
|
|
} |
|
|
.add-color-btn { |
|
|
background-color: #10b981; |
|
|
} |
|
|
.add-color-btn:hover { |
|
|
background-color: #059669; |
|
|
} |
|
|
.remove-color-btn { |
|
|
background-color: #ef4444; |
|
|
margin-left: 10px; |
|
|
} |
|
|
.remove-color-btn:hover { |
|
|
background-color: #dc2626; |
|
|
} |
|
|
.color-input-group { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 10px; |
|
|
} |
|
|
.category-list, .order-list, .product-list { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 15px; |
|
|
} |
|
|
.category-item, .order-item, .product-item { |
|
|
background: #fff; |
|
|
padding: 15px; |
|
|
border-radius: 10px; |
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); |
|
|
} |
|
|
.category-item { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
} |
|
|
.subcategory-list { |
|
|
margin-top: 10px; |
|
|
padding-left: 20px; |
|
|
} |
|
|
.subcategory-item { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
padding: 5px 0; |
|
|
} |
|
|
.order-item { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 10px; |
|
|
} |
|
|
.order-details { |
|
|
margin-left: 20px; |
|
|
} |
|
|
.product-item { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 10px; |
|
|
} |
|
|
details { |
|
|
margin-top: 10px; |
|
|
} |
|
|
summary { |
|
|
cursor: pointer; |
|
|
font-weight: 500; |
|
|
color: #3b82f6; |
|
|
} |
|
|
.edit-form { |
|
|
margin-top: 10px; |
|
|
padding: 10px; |
|
|
border: 1px solid #e2e8f0; |
|
|
border-radius: 5px; |
|
|
} |
|
|
#product-search { |
|
|
width: 100%; |
|
|
padding: 10px; |
|
|
margin-bottom: 20px; |
|
|
border: 1px solid #e2e8f0; |
|
|
border-radius: 5px; |
|
|
font-size: 1rem; |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<h1>Админ-панель Optomshop</h1> |
|
|
<div class="form-section"> |
|
|
<h2>Управление категориями</h2> |
|
|
<form method="POST"> |
|
|
<input type="hidden" name="action" value="add_category"> |
|
|
<label>Добавить категорию:</label> |
|
|
<input type="text" name="category_name" placeholder="Название категории" required> |
|
|
<button type="submit">Добавить</button> |
|
|
</form> |
|
|
<div class="category-list"> |
|
|
{% for category in categories %} |
|
|
<div class="category-item"> |
|
|
<span>{{ category['name'] }}</span> |
|
|
<form method="POST"> |
|
|
<input type="hidden" name="action" value="delete_category"> |
|
|
<input type="hidden" name="category_index" value="{{ loop.index0 }}"> |
|
|
<button type="submit" class="delete-button">Удалить</button> |
|
|
</form> |
|
|
</div> |
|
|
<div class="subcategory-list"> |
|
|
{% for subcat in category['subcategories'] %} |
|
|
<div class="subcategory-item"> |
|
|
<span>{{ subcat }}</span> |
|
|
<form method="POST"> |
|
|
<input type="hidden" name="action" value="delete_subcategory"> |
|
|
<input type="hidden" name="category_index" value="{{ loop.index0 }}"> |
|
|
<input type="hidden" name="subcategory_index" value="{{ loop.index0 }}"> |
|
|
<button type="submit" class="delete-button">Удалить</button> |
|
|
</form> |
|
|
</div> |
|
|
{% endfor %} |
|
|
<form method="POST"> |
|
|
<input type="hidden" name="action" value="add_subcategory"> |
|
|
<input type="hidden" name="category_index" value="{{ loop.index0 }}"> |
|
|
<label>Добавить подкатегорию:</label> |
|
|
<input type="text" name="subcategory_name" placeholder="Название подкатегории" required> |
|
|
<button type="submit">Добавить</button> |
|
|
</form> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
</div> |
|
|
<div class="form-section"> |
|
|
<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="text" name="article"> |
|
|
<label>Цена:</label> |
|
|
<input type="number" name="price" step="1" required> |
|
|
<label>Описание:</label> |
|
|
<textarea name="description" rows="4" required></textarea> |
|
|
<label>Размеры:</label> |
|
|
<input type="text" name="size"> |
|
|
<label>Категория:</label> |
|
|
<select name="category" onchange="updateSubcategories(this)"> |
|
|
<option value="">Без категории</option> |
|
|
{% for category in categories %} |
|
|
<option value="{{ category['name'] }}">{{ category['name'] }}</option> |
|
|
{% endfor %} |
|
|
</select> |
|
|
<label>Подкатегория:</label> |
|
|
<select name="subcategory" id="subcategory-select"> |
|
|
<option value="">Без подкатегории</option> |
|
|
</select> |
|
|
<label>Фотографии (до 10):</label> |
|
|
<input type="file" name="photos" accept="image/*" multiple> |
|
|
<label>Цвета или расцветки:</label> |
|
|
<div id="color-inputs"></div> |
|
|
<button type="button" class="add-color-btn" onclick="addColorInput()">Добавить цвет</button> |
|
|
<button type="submit">Добавить товар</button> |
|
|
</form> |
|
|
</div> |
|
|
<div class="form-section"> |
|
|
<h2>Список товаров</h2> |
|
|
<input type="text" id="product-search" placeholder="Поиск товаров..."> |
|
|
<div class="product-list" id="product-list"> |
|
|
{% for product in products %} |
|
|
<div class="product-item" |
|
|
data-name="{{ product['name']|lower }}" |
|
|
data-description="{{ product['description']|lower }}"> |
|
|
<h3>{{ product['name'] }}</h3> |
|
|
<p><strong>Артикул:</strong> {{ product.get('article', 'Не указан') }}</p> |
|
|
<p><strong>Размеры:</strong> {{ product.get('size', 'Не указан') }}</p> |
|
|
<p><strong>Категория:</strong> {{ product.get('category', 'Не указана') }}</p> |
|
|
<p><strong>Подкатегория:</strong> {{ product.get('subcategory', 'Не указана') }}</p> |
|
|
<p><strong>Цена:</strong> {{ product['price']|int }} сом</p> |
|
|
<p><strong>Описание:</strong> {{ product['description'] }}</p> |
|
|
<p><strong>Цвета:</strong> {{ product.get('colors', ['Нет цветов'])|join(', ') }}</p> |
|
|
{% if product.get('photos') and product['photos']|length > 0 %} |
|
|
<div style="display: flex; flex-wrap: wrap; gap: 10px;"> |
|
|
{% for photo in product['photos'] %} |
|
|
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}" |
|
|
alt="{{ product['name'] }}" |
|
|
style="max-width: 100px; border-radius: 10px;"> |
|
|
{% endfor %} |
|
|
</div> |
|
|
{% endif %} |
|
|
<details> |
|
|
<summary>Редактировать</summary> |
|
|
<form method="POST" enctype="multipart/form-data" class="edit-form"> |
|
|
<input type="hidden" name="action" value="edit"> |
|
|
<input type="hidden" name="index" value="{{ loop.index0 }}"> |
|
|
<label>Название:</label> |
|
|
<input type="text" name="name" value="{{ product['name'] }}" required> |
|
|
<label>Артикул:</label> |
|
|
<input type="text" name="article" value="{{ product.get('article', '') }}"> |
|
|
<label>Цена:</label> |
|
|
<input type="number" name="price" step="1" value="{{ product['price']|int }}" required> |
|
|
<label>Описание:</label> |
|
|
<textarea name="description" rows="4" required>{{ product['description'] }}</textarea> |
|
|
<label>Размеры:</label> |
|
|
<input type="text" name="size" value="{{ product.get('size', '') }}"> |
|
|
<label>Категория:</label> |
|
|
<select name="category" onchange="updateSubcategories(this, 'edit-subcategory-{{ loop.index0 }}')"> |
|
|
<option value="">Без категории</option> |
|
|
{% for category in categories %} |
|
|
<option value="{{ category['name'] }}" {% if product.get('category') == category['name'] %}selected{% endif %}>{{ category['name'] }}</option> |
|
|
{% endfor %} |
|
|
</select> |
|
|
<label>Подкатегория:</label> |
|
|
<select name="subcategory" id="edit-subcategory-{{ loop.index0 }}"> |
|
|
<option value="">Без подкатегории</option> |
|
|
{% for category in categories %} |
|
|
{% if category['name'] == product.get('category') %} |
|
|
{% for subcat in category['subcategories'] %} |
|
|
<option value="{{ subcat }}" {% if product.get('subcategory') == subcat %}selected{% endif %}>{{ subcat }}</option> |
|
|
{% endfor %} |
|
|
{% endif %} |
|
|
{% endfor %} |
|
|
</select> |
|
|
<label>Фотографии (до 10):</label> |
|
|
<input type="file" name="photos" accept="image/*" multiple> |
|
|
<label>Цвета или расцветки:</label> |
|
|
<div id="edit-color-inputs-{{ loop.index0 }}"> |
|
|
{% for color in product.get('colors', []) %} |
|
|
<div class="color-input-group"> |
|
|
<input type="text" name="colors" value="{{ color }}"> |
|
|
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)">Удалить</button> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
<button type="button" class="add-color-btn" onclick="addColorInput('edit-color-inputs-{{ loop.index0 }}')">Добавить цвет</button> |
|
|
<button type="submit">Сохранить</button> |
|
|
</form> |
|
|
</details> |
|
|
<form method="POST"> |
|
|
<input type="hidden" name="action" value="delete"> |
|
|
<input type="hidden" name="index" value="{{ loop.index0 }}"> |
|
|
<button type="submit" class="delete-button">Удалить</button> |
|
|
</form> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
</div> |
|
|
<div class="form-section"> |
|
|
<h2>Список заказов</h2> |
|
|
<div class="order-list"> |
|
|
{% for order in orders %} |
|
|
<div class="order-item"> |
|
|
<p><strong>Дата:</strong> {{ order['timestamp'] }}</p> |
|
|
<p><strong>Итого:</strong> {{ order['total'] }} сом</p> |
|
|
<div class="order-details"> |
|
|
{% for item in order['items'] %} |
|
|
<p>{{ item['name'] }} - {{ item['price'] }} сом × {{ item['quantity'] }} (Цвет: {{ item['color'] }})</p> |
|
|
{% endfor %} |
|
|
</div> |
|
|
<form method="POST"> |
|
|
<input type="hidden" name="action" value="download_order"> |
|
|
<input type="hidden" name="order_index" value="{{ loop.index0 }}"> |
|
|
<button type="submit">Скачать в Excel</button> |
|
|
</form> |
|
|
<form method="POST"> |
|
|
<input type="hidden" name="action" value="delete_order"> |
|
|
<input type="hidden" name="order_index" value="{{ loop.index0 }}"> |
|
|
<button type="submit" class="delete-button">Удалить</button> |
|
|
</form> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<script> |
|
|
const categories = {{ categories|tojson }}; |
|
|
|
|
|
function addColorInput(containerId = 'color-inputs') { |
|
|
const container = document.getElementById(containerId); |
|
|
const newInputGroup = document.createElement('div'); |
|
|
newInputGroup.className = 'color-input-group'; |
|
|
newInputGroup.innerHTML = ` |
|
|
<input type="text" name="colors" placeholder="Например: Красный"> |
|
|
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)">Удалить</button> |
|
|
`; |
|
|
container.appendChild(newInputGroup); |
|
|
} |
|
|
|
|
|
function removeColorInput(button) { |
|
|
const colorInputGroup = button.parentNode; |
|
|
colorInputGroup.remove(); |
|
|
} |
|
|
|
|
|
function updateSubcategories(select, targetId = 'subcategory-select') { |
|
|
const categoryName = select.value; |
|
|
const subcategorySelect = document.getElementById(targetId); |
|
|
subcategorySelect.innerHTML = '<option value="">Без подкатегории</option>'; |
|
|
if (categoryName) { |
|
|
const category = categories.find(cat => cat.name === categoryName); |
|
|
if (category && category.subcategories) { |
|
|
category.subcategories.forEach(subcat => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = subcat; |
|
|
option.text = subcat; |
|
|
subcategorySelect.appendChild(option); |
|
|
}); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
document.getElementById('product-search').addEventListener('input', filterProducts); |
|
|
|
|
|
function filterProducts() { |
|
|
const searchTerm = document.getElementById('product-search').value.toLowerCase(); |
|
|
document.querySelectorAll('.product-item').forEach(product => { |
|
|
const name = product.dataset.name; |
|
|
const description = product.dataset.description; |
|
|
const matchesSearch = name.includes(searchTerm) || description.includes(searchTerm); |
|
|
product.style.display = matchesSearch ? 'block' : 'none'; |
|
|
}); |
|
|
} |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
''' |
|
|
|
|
|
@app.route('/backup', methods=['POST']) |
|
|
def backup(): |
|
|
upload_db_to_hf() |
|
|
return "Резервная копия создана.", 200 |
|
|
|
|
|
@app.route('/download', methods=['GET']) |
|
|
def download(): |
|
|
download_db_from_hf() |
|
|
return "База данных скачана.", 200 |
|
|
|
|
|
if __name__ == '__main__': |
|
|
backup_thread = threading.Thread(target=periodic_backup) |
|
|
backup_thread.daemon = True |
|
|
backup_thread.start() |
|
|
app.run(debug=True, host='0.0.0.0', port=7860) |