Kgshop commited on
Commit
43b58b7
·
verified ·
1 Parent(s): ee5e2de

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +296 -233
app.py CHANGED
@@ -150,25 +150,40 @@ def load_data():
150
  if 'categories' not in data: data['categories'] = []
151
  if 'orders' not in data: data['orders'] = {}
152
 
153
- if 'categories' in data and data['categories'] and isinstance(data['categories'][0], str):
154
- logging.info("Old category format detected. Migrating to new structure.")
155
- data['categories'] = [{'name': c, 'subcategories': []} for c in data['categories']]
 
 
 
 
 
 
 
 
 
 
 
 
156
 
 
157
  for product in data['products']:
158
  if 'subcategory' not in product:
159
- product['subcategory'] = None
 
160
  if 'prices' not in product or not isinstance(product['prices'], list):
161
- if 'price' in product:
162
  product['prices'] = [{'type': 'шт', 'value': product.pop('price')}]
163
  else:
164
  product['prices'] = []
 
165
  product['prices'] = [p for p in product['prices'] if isinstance(p, dict) and 'type' in p and 'value' in p]
166
  for p in product['prices']:
167
  try:
168
  p['value'] = round(float(p['value']), 2)
169
  except (ValueError, TypeError):
170
  p['value'] = 0.0
171
- if not product['prices']:
172
  product['prices'] = [{'type': 'шт', 'value': 0.0}]
173
 
174
  return data
@@ -182,6 +197,7 @@ def load_data():
182
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
183
  data = json.load(file)
184
  logging.info(f"Data loaded successfully from {DATA_FILE} after download.")
 
185
  if not isinstance(data, dict):
186
  logging.error(f"Downloaded {DATA_FILE} is not a dictionary. Using default.")
187
  return default_data
@@ -189,15 +205,26 @@ def load_data():
189
  if 'categories' not in data: data['categories'] = []
190
  if 'orders' not in data: data['orders'] = {}
191
 
192
- if 'categories' in data and data['categories'] and isinstance(data['categories'][0], str):
193
- logging.info("Old category format detected after download. Migrating to new structure.")
194
- data['categories'] = [{'name': c, 'subcategories': []} for c in data['categories']]
 
 
 
 
 
 
 
 
 
 
195
 
196
  for product in data['products']:
197
  if 'subcategory' not in product:
198
- product['subcategory'] = None
 
199
  if 'prices' not in product or not isinstance(product['prices'], list):
200
- if 'price' in product:
201
  product['prices'] = [{'type': 'шт', 'value': product.pop('price')}]
202
  else:
203
  product['prices'] = []
@@ -266,14 +293,13 @@ CATALOG_TEMPLATE = '''
266
  .header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #e0e0e0; }
267
  .header h1 { font-size: 1.8rem; font-weight: 600; color: #e3a84f; }
268
  .store-address { padding: 15px; text-align: center; background-color: #f9f9f9; margin: 20px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.03); font-size: 1rem; color: #666; }
269
- .filters-wrapper { margin: 20px 0; }
270
- .filters-container, .sub-filters-container { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
271
- .sub-filters-container { margin-top: 15px; padding-top: 15px; border-top: 1px dashed #e0e0e0;}
272
  .search-container { margin: 20px 0; text-align: center; }
273
  #search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid #e0e0e0; border-radius: 25px; outline: none; box-shadow: 0 2px 5px rgba(0,0,0,0.03); transition: all 0.3s ease; }
274
  #search-input:focus { border-color: #e3a84f; box-shadow: 0 0 0 3px rgba(227, 168, 79, 0.15); }
275
- .category-filter, .subcategory-filter { padding: 8px 16px; border: 1px solid #e0e0e0; border-radius: 20px; background-color: #fff; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); font-size: 0.9rem; font-weight: 400; color: #c89345; }
276
- .category-filter.active, .category-filter:hover, .subcategory-filter.active, .subcategory-filter:hover { background-color: #e3a84f; color: white; border-color: #e3a84f; box-shadow: 0 2px 10px rgba(227, 168, 79, 0.2); }
 
277
  .products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 20px; padding: 10px; }
278
  @media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } }
279
  @media (min-width: 900px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
@@ -340,14 +366,14 @@ CATALOG_TEMPLATE = '''
340
 
341
  <div class="store-address">Наш адрес: {{ store_address }}</div>
342
 
343
- <div class="filters-wrapper">
344
- <div class="filters-container">
345
- <button class="category-filter active" data-category="all">Все категории</button>
346
- {% for category in categories %}
347
- <button class="category-filter" data-category="{{ category.name }}">{{ category.name }}</button>
 
348
  {% endfor %}
349
- </div>
350
- <div class="sub-filters-container" id="sub-filters-container"></div>
351
  </div>
352
 
353
  <div class="search-container">
@@ -360,7 +386,7 @@ CATALOG_TEMPLATE = '''
360
  data-name="{{ product['name']|lower }}"
361
  data-description="{{ product.get('description', '')|lower }}"
362
  data-category="{{ product.get('category', 'Без категории') }}"
363
- data-subcategory="{{ product.get('subcategory', '')|lower if product.get('subcategory') else '' }}">
364
  {% if product.get('is_top', False) %}
365
  <span class="top-product-indicator"><i class="fas fa-star"></i> Топ</span>
366
  {% endif %}
@@ -384,7 +410,7 @@ CATALOG_TEMPLATE = '''
384
  <span class="price-item">Нет цены</span>
385
  {% endif %}
386
  </div>
387
- <p class="product-description">{{ product.get('description', '')[:50] }}{% if product.get('description', '')|length > 50 %}...{% endif %}</p>
388
  </div>
389
  <div class="product-actions">
390
  <button class="product-button" onclick="openModal({{ loop.index0 }})">Подробнее</button>
@@ -453,13 +479,11 @@ CATALOG_TEMPLATE = '''
453
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
454
  <script>
455
  const products = {{ products|tojson }};
456
- const categoriesData = {{ categories|tojson }};
457
  const repoId = '{{ repo_id }}';
458
  const currencyCode = '{{ currency_code }}';
459
  let selectedProductIndex = null;
460
  let cart = JSON.parse(localStorage.getItem('mekaCart') || '[]');
461
- let activeCategory = 'all';
462
- let activeSubcategory = 'all';
463
 
464
  function openModal(index) {
465
  loadProductDetails(index);
@@ -556,7 +580,7 @@ CATALOG_TEMPLATE = '''
556
  const option = document.createElement('option');
557
  option.value = price_item.type.trim();
558
  option.text = `${price_item.value.toFixed(2)} ${currencyCode} / ${price_item.type.trim()}`;
559
- option.dataset.priceValue = price_item.value; // Store price value in dataset
560
  priceTypeSelect.appendChild(option);
561
  });
562
  priceTypeSelect.style.display = 'block';
@@ -565,7 +589,7 @@ CATALOG_TEMPLATE = '''
565
  priceTypeSelect.style.display = 'none';
566
  if(priceTypeLabel) priceTypeLabel.style.display = 'none';
567
  alert("Для этого товара нет доступных цен.");
568
- return;
569
  }
570
 
571
 
@@ -586,7 +610,7 @@ CATALOG_TEMPLATE = '''
586
  const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A';
587
  const priceTypeSelect = document.getElementById('priceTypeSelect');
588
  const selectedPriceOption = priceTypeSelect.options[priceTypeSelect.selectedIndex];
589
- const priceType = selectedPriceOption ? selectedPriceOption.value : 'шт';
590
  const priceValue = selectedPriceOption ? parseFloat(selectedPriceOption.dataset.priceValue) : null;
591
 
592
 
@@ -607,7 +631,7 @@ CATALOG_TEMPLATE = '''
607
  return;
608
  }
609
 
610
- const cartItemId = `${product.name}-${priceType}-${color}`;
611
  const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
612
 
613
  if (existingItemIndex > -1) {
@@ -616,8 +640,8 @@ CATALOG_TEMPLATE = '''
616
  cart.push({
617
  id: cartItemId,
618
  name: product.name,
619
- price_type: priceType,
620
- price_value: priceValue,
621
  photo: product.photos && product.photos.length > 0 ? product.photos[0] : null,
622
  quantity: quantity,
623
  color: color
@@ -659,13 +683,13 @@ CATALOG_TEMPLATE = '''
659
  cartTotalElement.textContent = '0.00';
660
  } else {
661
  cartContent.innerHTML = cart.map(item => {
662
- const itemTotal = item.price_value * item.quantity;
663
  total += itemTotal;
664
  const photoUrl = item.photo
665
  ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}`
666
  : 'https://via.placeholder.com/60x60.png?text=N/A';
667
  const colorText = item.color && item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
668
- const priceTypeText = item.price_type ? `/${item.price_type}` : '';
669
 
670
  return `
671
  <div class="cart-item">
@@ -751,6 +775,11 @@ CATALOG_TEMPLATE = '''
751
 
752
  function filterProducts() {
753
  const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
 
 
 
 
 
754
  const grid = document.getElementById('products-grid');
755
  let visibleProducts = 0;
756
 
@@ -760,14 +789,24 @@ CATALOG_TEMPLATE = '''
760
  document.querySelectorAll('.products-grid .product').forEach(productElement => {
761
  const name = productElement.getAttribute('data-name');
762
  const description = productElement.getAttribute('data-description');
763
- const category = productElement.getAttribute('data-category');
764
- const subcategory = productElement.getAttribute('data-subcategory');
765
 
766
  const matchesSearch = !searchTerm || name.includes(searchTerm) || description.includes(searchTerm);
767
- const matchesCategory = activeCategory === 'all' || category === activeCategory;
768
- const matchesSubcategory = activeSubcategory === 'all' || subcategory === activeSubcategory;
 
 
 
 
 
 
 
 
 
 
769
 
770
- if (matchesSearch && matchesCategory && matchesSubcategory) {
771
  productElement.style.display = 'flex';
772
  visibleProducts++;
773
  } else {
@@ -787,38 +826,6 @@ CATALOG_TEMPLATE = '''
787
  grid.appendChild(p);
788
  }
789
  }
790
-
791
- function renderSubcategories() {
792
- const subFiltersContainer = document.getElementById('sub-filters-container');
793
- subFiltersContainer.innerHTML = '';
794
-
795
- if (activeCategory === 'all') {
796
- subFiltersContainer.style.display = 'none';
797
- return;
798
- }
799
-
800
- const categoryData = categoriesData.find(c => c.name === activeCategory);
801
- if (categoryData && categoryData.subcategories && categoryData.subcategories.length > 0) {
802
- subFiltersContainer.style.display = 'flex';
803
-
804
- let buttonsHTML = `<button class="subcategory-filter active" data-subcategory="all">Все в "${activeCategory}"</button>`;
805
- categoryData.subcategories.forEach(sub => {
806
- buttonsHTML += `<button class="subcategory-filter" data-subcategory="${sub.toLowerCase()}">${sub}</button>`;
807
- });
808
- subFiltersContainer.innerHTML = buttonsHTML;
809
-
810
- subFiltersContainer.querySelectorAll('.subcategory-filter').forEach(subFilter => {
811
- subFilter.addEventListener('click', function() {
812
- subFiltersContainer.querySelectorAll('.subcategory-filter').forEach(f => f.classList.remove('active'));
813
- this.classList.add('active');
814
- activeSubcategory = this.dataset.subcategory;
815
- filterProducts();
816
- });
817
- });
818
- } else {
819
- subFiltersContainer.style.display = 'none';
820
- }
821
- }
822
 
823
  function setupFilters() {
824
  const searchInput = document.getElementById('search-input');
@@ -830,9 +837,6 @@ CATALOG_TEMPLATE = '''
830
  filter.addEventListener('click', function() {
831
  categoryFilters.forEach(f => f.classList.remove('active'));
832
  this.classList.add('active');
833
- activeCategory = this.dataset.category;
834
- activeSubcategory = 'all';
835
- renderSubcategories();
836
  filterProducts();
837
  });
838
  });
@@ -922,7 +926,10 @@ PRODUCT_DETAIL_TEMPLATE = '''
922
  </div>
923
 
924
  <div style="margin-top: 20px; font-size: 1rem; line-height: 1.7; color: #333;">
925
- <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}{% if product.get('subcategory') %} / {{ product.get('subcategory') }}{% endif %}</p>
 
 
 
926
  <p style="font-size: 1.2rem; font-weight: bold; color: #c89345; margin-bottom: 10px;"><strong>Цена:</strong></p>
927
  <div class="price-list" style="text-align: left;">
928
  {% if product.get('prices') %}
@@ -942,8 +949,9 @@ PRODUCT_DETAIL_TEMPLATE = '''
942
  </div>
943
  </div>
944
  <style>
 
945
  #productModal .swiper-button-next, #productModal .swiper-button-prev {
946
- color: #e3a84f;
947
  }
948
  </style>
949
  '''
@@ -1028,7 +1036,7 @@ ORDER_TEMPLATE = '''
1028
  function sendOrderViaWhatsApp() {
1029
  const orderId = '{{ order.id }}';
1030
  const orderUrl = `{{ request.url }}`;
1031
- const whatsappNumber = "{{ whatsapp_number }}";
1032
 
1033
  let message = `Здравствуйте! Хочу подтвердить свой заказ на сайте "Мир праздника":%0A%0A`;
1034
  message += `*Номер заказа:* ${orderId}%0A`;
@@ -1067,7 +1075,6 @@ ADMIN_TEMPLATE = '''
1067
  h1 { font-size: 1.8rem; }
1068
  h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
1069
  h3 { font-size: 1.2rem; color: #c89345; margin-top: 20px; }
1070
- h4 { font-size: 1.1rem; color: #333; margin-top: 20px; }
1071
  .section { margin-bottom: 30px; padding: 20px; background-color: #f9f9f9; border: 1px solid #e0e0e0; border-radius: 8px; }
1072
  form { margin-bottom: 20px; }
1073
  label { font-weight: 500; margin-top: 10px; display: block; color: #666; font-size: 0.9rem;}
@@ -1092,8 +1099,8 @@ ADMIN_TEMPLATE = '''
1092
  .item strong { color: #333; }
1093
  .item .description { font-size: 0.85rem; color: #999; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
1094
  .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
1095
- .item-actions button:not(.delete-button) { background-color: #e3a84f; }
1096
- .item-actions button:not(.delete-button):hover { background-color: #c89345; }
1097
  .edit-form-container { margin-top: 15px; padding: 20px; background: #fffcf5; border: 1px dashed #e0e0e0; border-radius: 6px; display: none; }
1098
  details { background-color: #f9f9f9; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 20px; }
1099
  details > summary { cursor: pointer; font-weight: 600; color: #c89345; display: block; padding: 15px; border-bottom: 1px solid #e0e0e0; list-style: none; position: relative; }
@@ -1103,9 +1110,10 @@ ADMIN_TEMPLATE = '''
1103
  details .form-content { padding: 20px; }
1104
  .color-input-group, .price-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
1105
  .color-input-group input, .price-input-group input { flex-grow: 1; margin: 0; }
1106
- .price-input-group input[type="text"] { width: 100px; flex-grow: 0; }
1107
- .price-input-group input[type="number"] { flex-grow: 1; }
1108
- .price-input-group label { margin-top: 0; width: auto; display: inline-block; font-weight: normal; color: #333;}
 
1109
  .remove-color-btn, .remove-price-btn { background-color: #dc3545; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; }
1110
  .remove-color-btn:hover, .remove-price-btn:hover { background-color: #c82333; }
1111
  .add-color-btn, .add-price-btn { background-color: #f0c38b; color: #c89345; border: 1px solid #e0e0e0; }
@@ -1124,10 +1132,9 @@ ADMIN_TEMPLATE = '''
1124
  .status-indicator.in-stock { background-color: #d4edda; color: #155724; }
1125
  .status-indicator.out-of-stock { background-color: #f8d7da; color: #721c24; }
1126
  .status-indicator.top-product { background-color: #fff3cd; color: #856404; margin-left: 5px;}
1127
- .admin-price-list { margin-top: 5px; }
1128
- .admin-price-item { font-size: 0.85rem; color: #333; display: block; margin-bottom: 3px;}
1129
- .subcategory-list { list-style-type: none; padding-left: 20px; margin-top: 10px; }
1130
- .subcategory-item { background: #f0f0f0; padding: 5px 10px; border-radius: 4px; margin-bottom: 5px; display: flex; justify-content: space-between; align-items: center; font-size: 0.9rem; }
1131
  </style>
1132
  </head>
1133
  <body>
@@ -1154,7 +1161,7 @@ ADMIN_TEMPLATE = '''
1154
  <button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить БД</button>
1155
  </form>
1156
  <form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.');">
1157
- <button type="submit" class="button download-hf-button" title="Скачать файлы (перезапишет локальные)"><i class="fas fa-download"></i> Скачать БД</button>
1158
  </form>
1159
  </div>
1160
  <p style="font-size: 0.85rem; color: #999;">Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
@@ -1165,55 +1172,59 @@ ADMIN_TEMPLATE = '''
1165
  <div class="section">
1166
  <h2><i class="fas fa-tags"></i> Управление категориями</h2>
1167
  <details>
1168
- <summary><i class="fas fa-plus-circle"></i> Добавить новую основную категорию</summary>
1169
  <div class="form-content">
1170
  <form method="POST">
1171
  <input type="hidden" name="action" value="add_category">
1172
- <label for="add_category_name">Название новой категории:</label>
1173
  <input type="text" id="add_category_name" name="category_name" required>
1174
  <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button>
1175
  </form>
1176
  </div>
1177
  </details>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1178
 
1179
  <h3>Существующие категории:</h3>
1180
- {% if categories %}
1181
  <div class="item-list">
1182
- {% for category in categories %}
1183
- <div class="item">
1184
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
1185
- <strong>{{ category.name }}</strong>
1186
- <form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить кате��орию \'{{ category.name }}\' и все ее подкатегории?');">
1187
- <input type="hidden" name="action" value="delete_category">
1188
- <input type="hidden" name="category_name" value="{{ category.name }}">
1189
- <button type="submit" class="delete-button" style="padding: 5px 10px; font-size: 0.8rem; margin: 0;"><i class="fas fa-trash-alt"></i></button>
1190
- </form>
1191
- </div>
1192
- <hr style="border: 0; border-top: 1px solid #f0f0f0; margin: 10px 0;">
1193
- <h4>Подкатегории:</h4>
1194
- {% if category.subcategories %}
1195
- <ul class="subcategory-list">
1196
- {% for sub in category.subcategories %}
1197
- <li class="subcategory-item">
1198
- <span>{{ sub }}</span>
1199
- <form method="POST" style="margin:0;" onsubmit="return confirm('Удалить подкатегорию \'{{ sub }}\'?');">
1200
- <input type="hidden" name="action" value="delete_subcategory">
1201
- <input type="hidden" name="parent_category" value="{{ category.name }}">
1202
- <input type="hidden" name="subcategory_name" value="{{ sub }}">
1203
- <button type="submit" class="delete-button" style="padding: 3px 8px; font-size:0.7rem; margin:0;"><i class="fas fa-times"></i></button>
1204
- </form>
1205
- </li>
1206
- {% endfor %}
1207
- </ul>
1208
- {% else %}
1209
- <p style="font-size: 0.9rem; color: #999;">Подкатегорий нет.</p>
1210
- {% endif %}
1211
- <form method="POST" style="margin-top: 15px; display: flex; gap: 10px; align-items: center;">
1212
- <input type="hidden" name="action" value="add_subcategory">
1213
- <input type="hidden" name="parent_category" value="{{ category.name }}">
1214
- <input type="text" name="subcategory_name" placeholder="Новая подкатегория" required style="margin:0; flex-grow: 1;">
1215
- <button type="submit" class="add-button" style="margin:0; padding: 8px 12px;"><i class="fas fa-plus"></i></button>
1216
- </form>
1217
  </div>
1218
  {% endfor %}
1219
  </div>
@@ -1259,18 +1270,18 @@ ADMIN_TEMPLATE = '''
1259
  <textarea id="add_description" name="description" rows="4"></textarea>
1260
 
1261
  <label for="add_category">Категория:</label>
1262
- <select id="add_category" name="category" onchange="updateSubcategoryDropdown('add', this.value)">
1263
  <option value="Без категории">Без категории</option>
1264
- {% for category in categories %}
1265
  <option value="{{ category.name }}">{{ category.name }}</option>
1266
  {% endfor %}
1267
  </select>
1268
 
1269
  <label for="add_subcategory">Подкатегория:</label>
1270
  <select id="add_subcategory" name="subcategory">
1271
- <option value="none">-- Сначала выберите категорию --</option>
1272
  </select>
1273
-
1274
  <label for="add_photos">Фотографии (до 10 шт.):</label>
1275
  <input type="file" id="add_photos" name="photos" accept="image/*" multiple>
1276
  <label>Цвета/Варианты (оставьте пустым, если нет):</label>
@@ -1323,7 +1334,8 @@ ADMIN_TEMPLATE = '''
1323
  <span class="status-indicator top-product"><i class="fas fa-star"></i> Топ</span>
1324
  {% endif %}
1325
  </h3>
1326
- <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}{% if product.get('subcategory') %} / {{ product.get('subcategory') }}{% endif %}</p>
 
1327
  <p><strong>Цены:</strong></p>
1328
  <div class="admin-price-list">
1329
  {% if product.get('prices') %}
@@ -1391,16 +1403,19 @@ ADMIN_TEMPLATE = '''
1391
  <textarea name="description" rows="4">{{ product.get('description', '') }}</textarea>
1392
 
1393
  <label>Категория:</label>
1394
- <select name="category" onchange="updateSubcategoryDropdown('edit_{{ loop.index0 }}', this.value, '{{ product.get('subcategory') }}')">
 
 
 
1395
  <option value="Без категории" {% if product.get('category', 'Без категории') == 'Без категории' %}selected{% endif %}>Без категории</option>
1396
- {% for category in categories %}
1397
  <option value="{{ category.name }}" {% if product.get('category') == category.name %}selected{% endif %}>{{ category.name }}</option>
1398
  {% endfor %}
1399
  </select>
1400
-
1401
  <label>Подкатегория:</label>
1402
- <select id="edit_{{ loop.index0 }}_subcategory" name="subcategory">
1403
- <option value="none">-- Сначала выберите категорию --</option>
1404
  </select>
1405
 
1406
  <label>Заменить фотографии (выберите новые файлы, до 10 шт.):</label>
@@ -1457,45 +1472,64 @@ ADMIN_TEMPLATE = '''
1457
  </div>
1458
 
1459
  <script>
1460
- const categoriesData = {{ categories|tojson }};
 
 
 
 
1461
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1462
  function toggleEditForm(formId) {
1463
  const formContainer = document.getElementById(formId);
1464
  if (formContainer) {
1465
- const isDisplayed = formContainer.style.display === 'block';
1466
- formContainer.style.display = isDisplayed ? 'none' : 'block';
1467
- if (!isDisplayed) {
1468
- const catSelect = formContainer.querySelector('select[name="category"]');
 
1469
  if (catSelect) {
1470
- const currentProductSub = catSelect.getAttribute('data-current-subcategory');
1471
- updateSubcategoryDropdown(catSelect.onchange.toString().match(/'(edit_[\d]+)'/)[1], catSelect.value, currentProductSub);
 
 
1472
  }
1473
  }
1474
  }
1475
  }
1476
 
1477
- function updateSubcategoryDropdown(formIdPrefix, selectedCategory, currentSubcategory = null) {
1478
- const subcatSelect = document.getElementById(`${formIdPrefix}_subcategory`);
1479
- if (!subcatSelect) return;
1480
-
1481
- subcatSelect.innerHTML = '';
1482
-
1483
- const categoryData = categoriesData.find(c => c.name === selectedCategory);
1484
-
1485
- if (categoryData && categoryData.subcategories && categoryData.subcategories.length > 0) {
1486
- subcatSelect.disabled = false;
1487
- let optionsHtml = '<option value="none">-- Выберите подкатегорию --</option>';
1488
- categoryData.subcategories.forEach(sub => {
1489
- const isSelected = sub === currentSubcategory ? 'selected' : '';
1490
- optionsHtml += `<option value="${sub}" ${isSelected}>${sub}</option>`;
1491
- });
1492
- subcatSelect.innerHTML = optionsHtml;
1493
- } else {
1494
- subcatSelect.innerHTML = '<option value="none">-- Нет подкатегорий --</option>';
1495
- subcatSelect.disabled = true;
1496
- }
1497
- }
1498
-
1499
  function addColorInput(containerId) {
1500
  const container = document.getElementById(containerId);
1501
  if (container) {
@@ -1562,16 +1596,27 @@ ADMIN_TEMPLATE = '''
1562
  if(addPriceInputsDiv && addPriceInputsDiv.children.length === 0) {
1563
  addPriceInput('add-price-inputs');
1564
  }
1565
-
1566
- document.querySelectorAll('.edit-form-container').forEach((formContainer, index) => {
1567
- const catSelect = formContainer.querySelector('select[name="category"]');
1568
- const productSubcategory = "{{ products[" + index + "].get('subcategory', '') }}";
1569
- if (catSelect) {
1570
- const formIdPrefix = `edit_${index}`;
1571
- updateSubcategoryDropdown(formIdPrefix, catSelect.value, productSubcategory);
1572
- }
 
 
 
 
 
 
 
 
 
 
1573
  });
1574
  });
 
1575
  </script>
1576
  </body>
1577
  </html>
@@ -1582,7 +1627,7 @@ ADMIN_TEMPLATE = '''
1582
  def catalog():
1583
  data = load_data()
1584
  all_products = data.get('products', [])
1585
- categories = sorted(data.get('categories', []), key=lambda x: x['name'])
1586
 
1587
  products_in_stock = [p for p in all_products if p.get('in_stock', True)]
1588
  products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
@@ -1590,7 +1635,7 @@ def catalog():
1590
  return render_template_string(
1591
  CATALOG_TEMPLATE,
1592
  products=products_sorted,
1593
- categories=categories,
1594
  repo_id=REPO_ID,
1595
  store_address=STORE_ADDRESS,
1596
  currency_code=CURRENCY_CODE
@@ -1704,7 +1749,7 @@ def view_order(order_id):
1704
  def admin():
1705
  data = load_data()
1706
  products = data.get('products', [])
1707
- categories = data.get('categories', [])
1708
  if 'orders' not in data or not isinstance(data.get('orders'), dict):
1709
  data['orders'] = {}
1710
 
@@ -1717,75 +1762,80 @@ def admin():
1717
  category_name = request.form.get('category_name', '').strip()
1718
  if category_name and not any(c['name'] == category_name for c in categories):
1719
  categories.append({'name': category_name, 'subcategories': []})
1720
- data['categories'] = sorted(categories, key=lambda x: x['name'])
1721
  save_data(data)
1722
  logging.info(f"Category '{category_name}' added.")
1723
- flash(f"Категория '{category_name}' успешно добавлена.", 'success')
1724
  elif not category_name:
1725
  logging.warning("Attempted to add empty category.")
1726
  flash("Название категории не может быть пустым.", 'error')
1727
  else:
1728
  logging.warning(f"Category '{category_name}' already exists.")
1729
- flash(f"Категория '{category_name}' уже существует.", 'error')
1730
 
1731
  elif action == 'add_subcategory':
1732
- parent_category_name = request.form.get('parent_category')
1733
  subcategory_name = request.form.get('subcategory_name', '').strip()
1734
  parent_cat = next((c for c in categories if c['name'] == parent_category_name), None)
1735
 
1736
- if parent_cat and subcategory_name and subcategory_name not in parent_cat['subcategories']:
1737
- parent_cat['subcategories'].append(subcategory_name)
1738
- parent_cat['subcategories'].sort()
1739
- save_data(data)
1740
- flash(f"Подкатегория '{subcategory_name}' добавлена в '{parent_category_name}'.", 'success')
1741
- elif not parent_cat:
1742
- flash(f"Родительская категория '{parent_category_name}' не найдена.", 'error')
1743
- elif not subcategory_name:
1744
- flash("Название подкатегории не может быть пустым.", 'error')
1745
  else:
1746
- flash(f"Подкатегория '{subcategory_name}' уже существует в '{parent_category_name}'.", 'error')
1747
 
1748
  elif action == 'delete_category':
1749
  category_to_delete = request.form.get('category_name')
1750
- original_len = len(categories)
1751
- categories = [c for c in categories if c['name'] != category_to_delete]
1752
- if len(categories) < original_len:
1753
- updated_count = 0
1754
- for product in products:
1755
- if product.get('category') == category_to_delete:
1756
- product['category'] = 'Без категории'
1757
- product['subcategory'] = None
1758
- updated_count += 1
1759
- data['categories'] = categories
1760
- data['products'] = products
1761
- save_data(data)
1762
- logging.info(f"Category '{category_to_delete}' deleted. Updated products: {updated_count}.")
1763
- flash(f"Категория '{category_to_delete}' удалена. {updated_count} товаров обновлено.", 'success')
1764
- else:
1765
- flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error')
1766
-
1767
- elif action == 'delete_subcategory':
1768
- parent_category_name = request.form.get('parent_category')
1769
  subcategory_to_delete = request.form.get('subcategory_name')
1770
- parent_cat = next((c for c in categories if c['name'] == parent_category_name), None)
1771
 
1772
- if parent_cat and subcategory_to_delete in parent_cat['subcategories']:
1773
- parent_cat['subcategories'].remove(subcategory_to_delete)
1774
- updated_count = 0
1775
- for product in products:
1776
- if product.get('category') == parent_category_name and product.get('subcategory') == subcategory_to_delete:
1777
- product['subcategory'] = None
1778
- updated_count += 1
1779
- save_data(data)
1780
- flash(f"Подкатегория '{subcategory_to_delete}' удалена. {updated_count} товаров обновлено.", 'success')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1781
  else:
1782
- flash(f"Не удалось удалить подкатегорию '{subcategory_to_delete}'.", 'error')
 
1783
 
1784
  elif action == 'add_product':
1785
  name = request.form.get('name', '').strip()
1786
  description = request.form.get('description', '').strip()
1787
  category = request.form.get('category')
1788
- subcategory = request.form.get('subcategory')
1789
  photos_files = request.files.getlist('photos')
1790
  colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
1791
  in_stock = 'in_stock' in request.form
@@ -1799,7 +1849,7 @@ def admin():
1799
  type_str = type_str.strip()
1800
  value_str = value_str.strip().replace(',', '.')
1801
  if not type_str or not value_str:
1802
- continue
1803
 
1804
  try:
1805
  price_value = round(float(value_str), 2)
@@ -1809,12 +1859,18 @@ def admin():
1809
  except ValueError:
1810
  logging.warning(f"Skipping invalid price value '{value_str}' for type '{type_str}' during add product.")
1811
 
 
1812
  if not name:
1813
  flash("Название товара обязательно.", 'error')
1814
  return redirect(url_for('admin'))
1815
  if not valid_prices_exist:
1816
  flash("Должен быть указан хотя бы один действительный вариант цены (Тип и Цена > 0).", 'error')
1817
  return redirect(url_for('admin'))
 
 
 
 
 
1818
 
1819
 
1820
  photos_list = []
@@ -1873,8 +1929,7 @@ def admin():
1873
 
1874
  new_product = {
1875
  'name': name, 'prices': prices, 'description': description,
1876
- 'category': category if any(c['name'] == category for c in categories) else 'Без категории',
1877
- 'subcategory': subcategory if subcategory and subcategory != 'none' else None,
1878
  'photos': photos_list, 'colors': colors,
1879
  'in_stock': in_stock, 'is_top': is_top
1880
  }
@@ -1904,10 +1959,18 @@ def admin():
1904
 
1905
  product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
1906
  product_to_edit['description'] = request.form.get('description', product_to_edit.get('description', '')).strip()
1907
- category = request.form.get('category')
1908
- subcategory = request.form.get('subcategory')
1909
- product_to_edit['category'] = category if any(c['name'] == category for c in categories) else 'Без категории'
1910
- product_to_edit['subcategory'] = subcategory if subcategory and subcategory != 'none' else None
 
 
 
 
 
 
 
 
1911
  product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
1912
  product_to_edit['in_stock'] = 'in_stock' in request.form
1913
  product_to_edit['is_top'] = 'is_top' in request.form
@@ -1920,7 +1983,7 @@ def admin():
1920
  type_str = type_str.strip()
1921
  value_str = value_str.strip().replace(',', '.')
1922
  if not type_str or not value_str:
1923
- continue
1924
 
1925
  try:
1926
  price_value = round(float(value_str), 2)
@@ -2070,12 +2133,12 @@ def admin():
2070
 
2071
  current_data = load_data()
2072
  display_products = sorted(current_data.get('products', []), key=lambda p: p.get('name', '').lower())
2073
- display_categories = sorted(current_data.get('categories', []), key=lambda x: x.get('name', ''))
2074
 
2075
  return render_template_string(
2076
  ADMIN_TEMPLATE,
2077
  products=display_products,
2078
- categories=display_categories,
2079
  repo_id=REPO_ID,
2080
  currency_code=CURRENCY_CODE
2081
  )
 
150
  if 'categories' not in data: data['categories'] = []
151
  if 'orders' not in data: data['orders'] = {}
152
 
153
+ # 1. Ensure categories are in dictionary format with subcategories (Migration)
154
+ if 'categories' in data:
155
+ new_categories = []
156
+ for cat in data['categories']:
157
+ if isinstance(cat, str): # Old flat structure migration
158
+ new_categories.append({'name': cat, 'subcategories': []})
159
+ elif isinstance(cat, dict) and 'name' in cat:
160
+ if 'subcategories' not in cat or not isinstance(cat['subcategories'], list):
161
+ cat['subcategories'] = []
162
+ new_categories.append(cat)
163
+ else:
164
+ logging.warning(f"Skipping invalid category entry: {cat}")
165
+ data['categories'] = new_categories
166
+ else:
167
+ data['categories'] = []
168
 
169
+ # 2. Ensure products have 'prices' and 'subcategory' field
170
  for product in data['products']:
171
  if 'subcategory' not in product:
172
+ product['subcategory'] = 'Без подкатегории'
173
+
174
  if 'prices' not in product or not isinstance(product['prices'], list):
175
+ if 'price' in product: # Convert old 'price' to new 'prices' structure
176
  product['prices'] = [{'type': 'шт', 'value': product.pop('price')}]
177
  else:
178
  product['prices'] = []
179
+
180
  product['prices'] = [p for p in product['prices'] if isinstance(p, dict) and 'type' in p and 'value' in p]
181
  for p in product['prices']:
182
  try:
183
  p['value'] = round(float(p['value']), 2)
184
  except (ValueError, TypeError):
185
  p['value'] = 0.0
186
+ if not product['prices']:
187
  product['prices'] = [{'type': 'шт', 'value': 0.0}]
188
 
189
  return data
 
197
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
198
  data = json.load(file)
199
  logging.info(f"Data loaded successfully from {DATA_FILE} after download.")
200
+
201
  if not isinstance(data, dict):
202
  logging.error(f"Downloaded {DATA_FILE} is not a dictionary. Using default.")
203
  return default_data
 
205
  if 'categories' not in data: data['categories'] = []
206
  if 'orders' not in data: data['orders'] = {}
207
 
208
+ # Apply same structure fixes after download
209
+ if 'categories' in data:
210
+ new_categories = []
211
+ for cat in data['categories']:
212
+ if isinstance(cat, str):
213
+ new_categories.append({'name': cat, 'subcategories': []})
214
+ elif isinstance(cat, dict) and 'name' in cat:
215
+ if 'subcategories' not in cat or not isinstance(cat['subcategories'], list):
216
+ cat['subcategories'] = []
217
+ new_categories.append(cat)
218
+ data['categories'] = new_categories
219
+ else:
220
+ data['categories'] = []
221
 
222
  for product in data['products']:
223
  if 'subcategory' not in product:
224
+ product['subcategory'] = 'Без подкатегории'
225
+
226
  if 'prices' not in product or not isinstance(product['prices'], list):
227
+ if 'price' in product:
228
  product['prices'] = [{'type': 'шт', 'value': product.pop('price')}]
229
  else:
230
  product['prices'] = []
 
293
  .header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #e0e0e0; }
294
  .header h1 { font-size: 1.8rem; font-weight: 600; color: #e3a84f; }
295
  .store-address { padding: 15px; text-align: center; background-color: #f9f9f9; margin: 20px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.03); font-size: 1rem; color: #666; }
296
+ .filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
 
 
297
  .search-container { margin: 20px 0; text-align: center; }
298
  #search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid #e0e0e0; border-radius: 25px; outline: none; box-shadow: 0 2px 5px rgba(0,0,0,0.03); transition: all 0.3s ease; }
299
  #search-input:focus { border-color: #e3a84f; box-shadow: 0 0 0 3px rgba(227, 168, 79, 0.15); }
300
+ .category-filter { padding: 8px 16px; border: 1px solid #e0e0e0; border-radius: 20px; background-color: #fff; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); font-size: 0.9rem; font-weight: 400; color: #c89345; white-space: nowrap;}
301
+ .category-filter.subcategory-filter { font-size: 0.8rem; padding: 6px 12px; }
302
+ .category-filter.active, .category-filter:hover { background-color: #e3a84f; color: white; border-color: #e3a84f; box-shadow: 0 2px 10px rgba(227, 168, 79, 0.2); }
303
  .products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 20px; padding: 10px; }
304
  @media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } }
305
  @media (min-width: 900px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
 
366
 
367
  <div class="store-address">Наш адрес: {{ store_address }}</div>
368
 
369
+ <div class="filters-container">
370
+ <button class="category-filter active" data-category="all" data-subcategory="all">Все категории</button>
371
+ {% for category_obj in categories_data %}
372
+ <button class="category-filter" data-category="{{ category_obj.name }}" data-subcategory="all">{{ category_obj.name }}</button>
373
+ {% for subcategory in category_obj.subcategories %}
374
+ <button class="category-filter subcategory-filter" data-category="{{ category_obj.name }}" data-subcategory="{{ subcategory }}">{{ subcategory }}</button>
375
  {% endfor %}
376
+ {% endfor %}
 
377
  </div>
378
 
379
  <div class="search-container">
 
386
  data-name="{{ product['name']|lower }}"
387
  data-description="{{ product.get('description', '')|lower }}"
388
  data-category="{{ product.get('category', 'Без категории') }}"
389
+ data-subcategory="{{ product.get('subcategory', 'Без подкатегории') }}">
390
  {% if product.get('is_top', False) %}
391
  <span class="top-product-indicator"><i class="fas fa-star"></i> Топ</span>
392
  {% endif %}
 
410
  <span class="price-item">Нет цены</span>
411
  {% endif %}
412
  </div>
413
+ <p class="product-description">{{ product.get('category', 'Без категории') }}{% if product.get('subcategory', 'Без подкатегории') != 'Без подкатегории' %} / {{ product.get('subcategory') }}{% endif %}</p>
414
  </div>
415
  <div class="product-actions">
416
  <button class="product-button" onclick="openModal({{ loop.index0 }})">Подробнее</button>
 
479
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
480
  <script>
481
  const products = {{ products|tojson }};
 
482
  const repoId = '{{ repo_id }}';
483
  const currencyCode = '{{ currency_code }}';
484
  let selectedProductIndex = null;
485
  let cart = JSON.parse(localStorage.getItem('mekaCart') || '[]');
486
+
 
487
 
488
  function openModal(index) {
489
  loadProductDetails(index);
 
580
  const option = document.createElement('option');
581
  option.value = price_item.type.trim();
582
  option.text = `${price_item.value.toFixed(2)} ${currencyCode} / ${price_item.type.trim()}`;
583
+ option.dataset.priceValue = price_item.value;
584
  priceTypeSelect.appendChild(option);
585
  });
586
  priceTypeSelect.style.display = 'block';
 
589
  priceTypeSelect.style.display = 'none';
590
  if(priceTypeLabel) priceTypeLabel.style.display = 'none';
591
  alert("Для этого товара нет доступных цен.");
592
+ return;
593
  }
594
 
595
 
 
610
  const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A';
611
  const priceTypeSelect = document.getElementById('priceTypeSelect');
612
  const selectedPriceOption = priceTypeSelect.options[priceTypeSelect.selectedIndex];
613
+ const priceType = selectedPriceOption ? selectedPriceOption.value : 'шт';
614
  const priceValue = selectedPriceOption ? parseFloat(selectedPriceOption.dataset.priceValue) : null;
615
 
616
 
 
631
  return;
632
  }
633
 
634
+ const cartItemId = `${product.name}-${priceType}-${color}`;
635
  const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
636
 
637
  if (existingItemIndex > -1) {
 
640
  cart.push({
641
  id: cartItemId,
642
  name: product.name,
643
+ price_type: priceType,
644
+ price_value: priceValue,
645
  photo: product.photos && product.photos.length > 0 ? product.photos[0] : null,
646
  quantity: quantity,
647
  color: color
 
683
  cartTotalElement.textContent = '0.00';
684
  } else {
685
  cartContent.innerHTML = cart.map(item => {
686
+ const itemTotal = item.price_value * item.quantity;
687
  total += itemTotal;
688
  const photoUrl = item.photo
689
  ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}`
690
  : 'https://via.placeholder.com/60x60.png?text=N/A';
691
  const colorText = item.color && item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
692
+ const priceTypeText = item.price_type ? `/${item.price_type}` : '';
693
 
694
  return `
695
  <div class="cart-item">
 
775
 
776
  function filterProducts() {
777
  const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
778
+ const activeCategoryButton = document.querySelector('.category-filter.active');
779
+
780
+ const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all';
781
+ const activeSubcategory = activeCategoryButton ? activeCategoryButton.dataset.subcategory : 'all';
782
+
783
  const grid = document.getElementById('products-grid');
784
  let visibleProducts = 0;
785
 
 
789
  document.querySelectorAll('.products-grid .product').forEach(productElement => {
790
  const name = productElement.getAttribute('data-name');
791
  const description = productElement.getAttribute('data-description');
792
+ const productCategory = productElement.getAttribute('data-category');
793
+ const productSubcategory = productElement.getAttribute('data-subcategory');
794
 
795
  const matchesSearch = !searchTerm || name.includes(searchTerm) || description.includes(searchTerm);
796
+
797
+ let matchesFilter = false;
798
+
799
+ if (activeCategory === 'all') {
800
+ matchesFilter = true;
801
+ } else if (productCategory === activeCategory) {
802
+ if (activeSubcategory === 'all') {
803
+ matchesFilter = true;
804
+ } else if (productSubcategory === activeSubcategory) {
805
+ matchesFilter = true;
806
+ }
807
+ }
808
 
809
+ if (matchesSearch && matchesFilter) {
810
  productElement.style.display = 'flex';
811
  visibleProducts++;
812
  } else {
 
826
  grid.appendChild(p);
827
  }
828
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
 
830
  function setupFilters() {
831
  const searchInput = document.getElementById('search-input');
 
837
  filter.addEventListener('click', function() {
838
  categoryFilters.forEach(f => f.classList.remove('active'));
839
  this.classList.add('active');
 
 
 
840
  filterProducts();
841
  });
842
  });
 
926
  </div>
927
 
928
  <div style="margin-top: 20px; font-size: 1rem; line-height: 1.7; color: #333;">
929
+ <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
930
+ {% if product.get('subcategory', 'Без подкатегории') != 'Без подкатегории' %}
931
+ <p><strong>Подкатегория:</strong> {{ product.get('subcategory') }}</p>
932
+ {% endif %}
933
  <p style="font-size: 1.2rem; font-weight: bold; color: #c89345; margin-bottom: 10px;"><strong>Цена:</strong></p>
934
  <div class="price-list" style="text-align: left;">
935
  {% if product.get('prices') %}
 
949
  </div>
950
  </div>
951
  <style>
952
+ /* Add Swiper Modal Specific Styles if needed */
953
  #productModal .swiper-button-next, #productModal .swiper-button-prev {
954
+ color: #e3a84f; /* Ensure modal navigation buttons match new color */
955
  }
956
  </style>
957
  '''
 
1036
  function sendOrderViaWhatsApp() {
1037
  const orderId = '{{ order.id }}';
1038
  const orderUrl = `{{ request.url }}`;
1039
+ const whatsappNumber = "{{ whatsapp_number }}";
1040
 
1041
  let message = `Здравствуйте! Хочу подтвердить свой заказ на сайте "Мир праздника":%0A%0A`;
1042
  message += `*Номер заказа:* ${orderId}%0A`;
 
1075
  h1 { font-size: 1.8rem; }
1076
  h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
1077
  h3 { font-size: 1.2rem; color: #c89345; margin-top: 20px; }
 
1078
  .section { margin-bottom: 30px; padding: 20px; background-color: #f9f9f9; border: 1px solid #e0e0e0; border-radius: 8px; }
1079
  form { margin-bottom: 20px; }
1080
  label { font-weight: 500; margin-top: 10px; display: block; color: #666; font-size: 0.9rem;}
 
1099
  .item strong { color: #333; }
1100
  .item .description { font-size: 0.85rem; color: #999; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
1101
  .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
1102
+ .item-actions button:not(.delete-button) { background-color: #e3a84f; }
1103
+ .item-actions button:not(.delete-button):hover { background-color: #c89345; }
1104
  .edit-form-container { margin-top: 15px; padding: 20px; background: #fffcf5; border: 1px dashed #e0e0e0; border-radius: 6px; display: none; }
1105
  details { background-color: #f9f9f9; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 20px; }
1106
  details > summary { cursor: pointer; font-weight: 600; color: #c89345; display: block; padding: 15px; border-bottom: 1px solid #e0e0e0; list-style: none; position: relative; }
 
1110
  details .form-content { padding: 20px; }
1111
  .color-input-group, .price-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
1112
  .color-input-group input, .price-input-group input { flex-grow: 1; margin: 0; }
1113
+ .price-input-group input[type="text"] { width: 100px; flex-grow: 0; }
1114
+ .price-input-group input[type="number"] { flex-grow: 1; }
1115
+ .price-input-group label { margin-top: 0; width: auto; display: inline-block; font-weight: normal; color: #333;}
1116
+
1117
  .remove-color-btn, .remove-price-btn { background-color: #dc3545; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; }
1118
  .remove-color-btn:hover, .remove-price-btn:hover { background-color: #c82333; }
1119
  .add-color-btn, .add-price-btn { background-color: #f0c38b; color: #c89345; border: 1px solid #e0e0e0; }
 
1132
  .status-indicator.in-stock { background-color: #d4edda; color: #155724; }
1133
  .status-indicator.out-of-stock { background-color: #f8d7da; color: #721c24; }
1134
  .status-indicator.top-product { background-color: #fff3cd; color: #856404; margin-left: 5px;}
1135
+ .admin-price-list { margin-top: 5px; }
1136
+ .admin-price-item { font-size: 0.85rem; color: #333; display: block; margin-bottom: 3px;}
1137
+ .category-item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.03); border: 1px solid #f0f0f0; }
 
1138
  </style>
1139
  </head>
1140
  <body>
 
1161
  <button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить БД</button>
1162
  </form>
1163
  <form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.');">
1164
+ <button type="submit" class="button download-hf-button" title="Скачать файлы (перезапишет локальные)"><i class="fas fa-download"></i> Скачать БД</button>
1165
  </form>
1166
  </div>
1167
  <p style="font-size: 0.85rem; color: #999;">Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
 
1172
  <div class="section">
1173
  <h2><i class="fas fa-tags"></i> Управление категориями</h2>
1174
  <details>
1175
+ <summary><i class="fas fa-plus-circle"></i> Добавить главную категорию</summary>
1176
  <div class="form-content">
1177
  <form method="POST">
1178
  <input type="hidden" name="action" value="add_category">
1179
+ <label for="add_category_name">Название новой главной категории:</label>
1180
  <input type="text" id="add_category_name" name="category_name" required>
1181
  <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button>
1182
  </form>
1183
  </div>
1184
  </details>
1185
+
1186
+ <details style="margin-top: 10px;">
1187
+ <summary><i class="fas fa-sitemap"></i> Добавить подкатегорию</summary>
1188
+ <div class="form-content">
1189
+ <form method="POST">
1190
+ <input type="hidden" name="action" value="add_subcategory">
1191
+ <label for="parent_category_name">Выберите главную категорию:</label>
1192
+ <select id="parent_category_name" name="parent_category_name" required>
1193
+ {% for category in categories_data %}
1194
+ <option value="{{ category.name }}">{{ category.name }}</option>
1195
+ {% endfor %}
1196
+ </select>
1197
+ <label for="add_subcategory_name">Название новой подкатегории:</label>
1198
+ <input type="text" id="add_subcategory_name" name="subcategory_name" required>
1199
+ <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить подкатегорию</button>
1200
+ </form>
1201
+ </div>
1202
+ </details>
1203
 
1204
  <h3>Существующие категории:</h3>
1205
+ {% if categories_data %}
1206
  <div class="item-list">
1207
+ {% for category in categories_data %}
1208
+ <div class="category-item">
1209
+ <div style="display: flex; justify-content: space-between; align-items: center; background: #f0f0f0; padding: 5px 10px; border-radius: 4px; margin-bottom: 5px;">
1210
+ <span><strong>{{ category.name }}</strong> (Главная)</span>
1211
+ <form method="POST" style="margin: 0;" onsubmit="return confirm('Удалить главную категорию \'{{ category.name }}\'? Это также удалит все подкатегории и обновит товары.');">
1212
+ <input type="hidden" name="action" value="delete_category">
1213
+ <input type="hidden" name="category_name" value="{{ category.name }}">
1214
+ <button type="submit" class="delete-button" style="padding: 5px 10px; font-size: 0.8rem; margin: 0;"><i class="fas fa-trash-alt"></i></button>
1215
+ </form>
1216
+ </div>
1217
+ {% for subcategory in category.subcategories %}
1218
+ <div style="display: flex; justify-content: space-between; align-items: center; padding: 5px 10px; margin-left: 15px; border-left: 2px solid #e3a84f;">
1219
+ <span style="font-size: 0.9em;">— {{ subcategory }} (Подкатегория)</span>
1220
+ <form method="POST" style="margin: 0;" onsubmit="return confirm('Удалить подкатегорию \'{{ subcategory }}\' из \'{{ category.name }}\'?');">
1221
+ <input type="hidden" name="action" value="delete_category">
1222
+ <input type="hidden" name="category_name" value="{{ category.name }}">
1223
+ <input type="hidden" name="subcategory_name" value="{{ subcategory }}">
1224
+ <button type="submit" class="delete-button" style="padding: 3px 8px; font-size: 0.7rem; margin: 0;"><i class="fas fa-times"></i></button>
1225
+ </form>
1226
+ </div>
1227
+ {% endfor %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1228
  </div>
1229
  {% endfor %}
1230
  </div>
 
1270
  <textarea id="add_description" name="description" rows="4"></textarea>
1271
 
1272
  <label for="add_category">Категория:</label>
1273
+ <select id="add_category" name="category">
1274
  <option value="Без категории">Без категории</option>
1275
+ {% for category in categories_data %}
1276
  <option value="{{ category.name }}">{{ category.name }}</option>
1277
  {% endfor %}
1278
  </select>
1279
 
1280
  <label for="add_subcategory">Подкатегория:</label>
1281
  <select id="add_subcategory" name="subcategory">
1282
+ <option value="Без подкатегории">Без подкатегории</option>
1283
  </select>
1284
+
1285
  <label for="add_photos">Фотографии (до 10 шт.):</label>
1286
  <input type="file" id="add_photos" name="photos" accept="image/*" multiple>
1287
  <label>Цвета/Варианты (оставьте пустым, если нет):</label>
 
1334
  <span class="status-indicator top-product"><i class="fas fa-star"></i> Топ</span>
1335
  {% endif %}
1336
  </h3>
1337
+ <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
1338
+ <p><strong>Подкатегория:</strong> {{ product.get('subcategory', 'Без подкатегории') }}</p>
1339
  <p><strong>Цены:</strong></p>
1340
  <div class="admin-price-list">
1341
  {% if product.get('prices') %}
 
1403
  <textarea name="description" rows="4">{{ product.get('description', '') }}</textarea>
1404
 
1405
  <label>Категория:</label>
1406
+ <select id="edit_category_{{ loop.index0 }}"
1407
+ name="category"
1408
+ data-initial-subcategory="{{ product.get('subcategory', 'Без подкатегории') }}"
1409
+ onchange="updateSubcategorySelect('edit_category_{{ loop.index0 }}', 'edit_subcategory_{{ loop.index0 }}')">
1410
  <option value="Без категории" {% if product.get('category', 'Без категории') == 'Без категории' %}selected{% endif %}>Без категории</option>
1411
+ {% for category in categories_data %}
1412
  <option value="{{ category.name }}" {% if product.get('category') == category.name %}selected{% endif %}>{{ category.name }}</option>
1413
  {% endfor %}
1414
  </select>
1415
+
1416
  <label>Подкатегория:</label>
1417
+ <select id="edit_subcategory_{{ loop.index0 }}" name="subcategory">
1418
+ <!-- Options populated by JS -->
1419
  </select>
1420
 
1421
  <label>Заменить фотографии (выберите новые файлы, до 10 шт.):</label>
 
1472
  </div>
1473
 
1474
  <script>
1475
+ const categoriesData = {{ categories_data|tojson }};
1476
+ const categorySubcategoryMap = {};
1477
+ categoriesData.forEach(c => {
1478
+ categorySubcategoryMap[c.name] = c.subcategories;
1479
+ });
1480
 
1481
+ function updateSubcategorySelect(categorySelectId, subcategorySelectId) {
1482
+ const categorySelect = document.getElementById(categorySelectId);
1483
+ const subcategorySelect = document.getElementById(subcategorySelectId);
1484
+ if (!categorySelect || !subcategorySelect) return;
1485
+
1486
+ const selectedCategory = categorySelect.value;
1487
+ const subcategories = categorySubcategoryMap[selectedCategory] || [];
1488
+
1489
+ // Determine the initial subcategory value for edit forms
1490
+ let currentSubcategory = 'Без подкатегории';
1491
+ if (categorySelect.dataset.initialSubcategory) {
1492
+ currentSubcategory = categorySelect.dataset.initialSubcategory;
1493
+ // Clear the data attribute after using it once, so manual changes don't get overwritten
1494
+ delete categorySelect.dataset.initialSubcategory;
1495
+ }
1496
+
1497
+ // If the main category changed, reset the current subcategory selection check
1498
+ if (categorySelect.value !== selectedCategory) {
1499
+ currentSubcategory = 'Без подкатегории';
1500
+ }
1501
+
1502
+ subcategorySelect.innerHTML = '<option value="Без подкатегории">Без подкатегории</option>';
1503
+
1504
+ subcategories.forEach(sub => {
1505
+ const option = document.createElement('option');
1506
+ option.value = sub;
1507
+ option.textContent = sub;
1508
+ if (sub === currentSubcategory) {
1509
+ option.selected = true;
1510
+ }
1511
+ subcategorySelect.appendChild(option);
1512
+ });
1513
+ }
1514
+
1515
  function toggleEditForm(formId) {
1516
  const formContainer = document.getElementById(formId);
1517
  if (formContainer) {
1518
+ formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none';
1519
+ if (formContainer.style.display === 'block') {
1520
+ // Re-initialize subcategory select on opening the edit form
1521
+ const index = formId.split('-').pop();
1522
+ const catSelect = document.getElementById(`edit_category_${index}`);
1523
  if (catSelect) {
1524
+ // Use the data attribute to restore the saved subcategory
1525
+ const initialSubcategory = catSelect.getAttribute('data-initial-subcategory');
1526
+ updateSubcategorySelect(`edit_category_${index}`, `edit_subcategory_${index}`, initialSubcategory);
1527
+ // Re-attach listener if not already done via onchange attribute
1528
  }
1529
  }
1530
  }
1531
  }
1532
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1533
  function addColorInput(containerId) {
1534
  const container = document.getElementById(containerId);
1535
  if (container) {
 
1596
  if(addPriceInputsDiv && addPriceInputsDiv.children.length === 0) {
1597
  addPriceInput('add-price-inputs');
1598
  }
1599
+
1600
+ // Setup for ADD form
1601
+ const addCatSelect = document.getElementById('add_category');
1602
+ if (addCatSelect) {
1603
+ updateSubcategorySelect('add_category', 'add_subcategory');
1604
+ addCatSelect.addEventListener('change', () => updateSubcategorySelect('add_category', 'add_subcategory'));
1605
+ }
1606
+
1607
+ // Initial setup for all EDIT forms (only run if the form is already open, otherwise done when opening via toggleEditForm)
1608
+ document.querySelectorAll('[id^="edit-form-"]').forEach(formContainer => {
1609
+ if (formContainer.style.display === 'block') {
1610
+ const index = formContainer.id.split('-').pop();
1611
+ const catSelect = document.getElementById(`edit_category_${index}`);
1612
+ if (catSelect) {
1613
+ const initialSubcategory = catSelect.getAttribute('data-initial-subcategory');
1614
+ updateSubcategorySelect(`edit_category_${index}`, `edit_subcategory_${index}`, initialSubcategory);
1615
+ }
1616
+ }
1617
  });
1618
  });
1619
+
1620
  </script>
1621
  </body>
1622
  </html>
 
1627
  def catalog():
1628
  data = load_data()
1629
  all_products = data.get('products', [])
1630
+ categories_data = data.get('categories', [])
1631
 
1632
  products_in_stock = [p for p in all_products if p.get('in_stock', True)]
1633
  products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
 
1635
  return render_template_string(
1636
  CATALOG_TEMPLATE,
1637
  products=products_sorted,
1638
+ categories_data=categories_data,
1639
  repo_id=REPO_ID,
1640
  store_address=STORE_ADDRESS,
1641
  currency_code=CURRENCY_CODE
 
1749
  def admin():
1750
  data = load_data()
1751
  products = data.get('products', [])
1752
+ categories = data.get('categories', [])
1753
  if 'orders' not in data or not isinstance(data.get('orders'), dict):
1754
  data['orders'] = {}
1755
 
 
1762
  category_name = request.form.get('category_name', '').strip()
1763
  if category_name and not any(c['name'] == category_name for c in categories):
1764
  categories.append({'name': category_name, 'subcategories': []})
1765
+ data['categories'] = categories
1766
  save_data(data)
1767
  logging.info(f"Category '{category_name}' added.")
1768
+ flash(f"Главная категория '{category_name}' успешно добавлена.", 'success')
1769
  elif not category_name:
1770
  logging.warning("Attempted to add empty category.")
1771
  flash("Название категории не может быть пустым.", 'error')
1772
  else:
1773
  logging.warning(f"Category '{category_name}' already exists.")
1774
+ flash(f"Главная категория '{category_name}' уже существует.", 'error')
1775
 
1776
  elif action == 'add_subcategory':
1777
+ parent_category_name = request.form.get('parent_category_name')
1778
  subcategory_name = request.form.get('subcategory_name', '').strip()
1779
  parent_cat = next((c for c in categories if c['name'] == parent_category_name), None)
1780
 
1781
+ if parent_cat and subcategory_name:
1782
+ if subcategory_name not in parent_cat['subcategories']:
1783
+ parent_cat['subcategories'].append(subcategory_name)
1784
+ save_data(data)
1785
+ flash(f"Подкатегория '{subcategory_name}' добавлена в '{parent_category_name}'.", 'success')
1786
+ else:
1787
+ flash(f"Подкатегория '{subcategory_name}' уже существует в этой категории.", 'error')
 
 
1788
  else:
1789
+ flash("Неверное название категории или подкатегории.", 'error')
1790
 
1791
  elif action == 'delete_category':
1792
  category_to_delete = request.form.get('category_name')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1793
  subcategory_to_delete = request.form.get('subcategory_name')
 
1794
 
1795
+ if subcategory_to_delete and category_to_delete:
1796
+ # Deleting a subcategory
1797
+ parent_cat = next((c for c in categories if c['name'] == category_to_delete), None)
1798
+ if parent_cat and subcategory_to_delete in parent_cat['subcategories']:
1799
+ parent_cat['subcategories'].remove(subcategory_to_delete)
1800
+
1801
+ # Update products whose category/subcategory matches
1802
+ updated_count = 0
1803
+ for product in products:
1804
+ if product.get('category') == category_to_delete and product.get('subcategory') == subcategory_to_delete:
1805
+ product['subcategory'] = 'Без подкатегории'
1806
+ updated_count += 1
1807
+
1808
+ save_data(data)
1809
+ flash(f"Подкатегория '{subcategory_to_delete}' из '{category_to_delete}' удалена. {updated_count} товаров обновлено.", 'success')
1810
+ else:
1811
+ flash(f"Не удалось найти подкатегорию '{subcategory_to_delete}' в '{category_to_delete}'.", 'error')
1812
+ elif category_to_delete and not subcategory_to_delete:
1813
+ # Deleting a main category
1814
+ if any(c['name'] == category_to_delete for c in categories):
1815
+ data['categories'] = [c for c in categories if c['name'] != category_to_delete]
1816
+ categories = data['categories']
1817
+
1818
+ updated_count = 0
1819
+ for product in products:
1820
+ if product.get('category') == category_to_delete:
1821
+ product['category'] = 'Без категории'
1822
+ product['subcategory'] = 'Без подкатегории'
1823
+ updated_count += 1
1824
+
1825
+ data['products'] = products
1826
+ save_data(data)
1827
+ flash(f"Главная категория '{category_to_delete}' удалена. {updated_count} товаров обновлено.", 'success')
1828
+ else:
1829
+ flash(f"Не удалось найти главную категорию '{category_to_delete}'.", 'error')
1830
  else:
1831
+ flash("Неверное действие удаления категории/подкатегории.", 'error')
1832
+
1833
 
1834
  elif action == 'add_product':
1835
  name = request.form.get('name', '').strip()
1836
  description = request.form.get('description', '').strip()
1837
  category = request.form.get('category')
1838
+ subcategory = request.form.get('subcategory', 'Без подкатегории').strip()
1839
  photos_files = request.files.getlist('photos')
1840
  colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
1841
  in_stock = 'in_stock' in request.form
 
1849
  type_str = type_str.strip()
1850
  value_str = value_str.strip().replace(',', '.')
1851
  if not type_str or not value_str:
1852
+ continue
1853
 
1854
  try:
1855
  price_value = round(float(value_str), 2)
 
1859
  except ValueError:
1860
  logging.warning(f"Skipping invalid price value '{value_str}' for type '{type_str}' during add product.")
1861
 
1862
+
1863
  if not name:
1864
  flash("Название товара обязательно.", 'error')
1865
  return redirect(url_for('admin'))
1866
  if not valid_prices_exist:
1867
  flash("Должен быть указан хотя бы один действительный вариант цены (Тип и Цена > 0).", 'error')
1868
  return redirect(url_for('admin'))
1869
+
1870
+ if category != 'Без категории' and not any(c['name'] == category for c in categories):
1871
+ category = 'Без категории'
1872
+ subcategory = 'Без подкатегории'
1873
+ flash("Выбранная категория не найдена. Установлено 'Без категории'.", 'warning')
1874
 
1875
 
1876
  photos_list = []
 
1929
 
1930
  new_product = {
1931
  'name': name, 'prices': prices, 'description': description,
1932
+ 'category': category, 'subcategory': subcategory,
 
1933
  'photos': photos_list, 'colors': colors,
1934
  'in_stock': in_stock, 'is_top': is_top
1935
  }
 
1959
 
1960
  product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
1961
  product_to_edit['description'] = request.form.get('description', product_to_edit.get('description', '')).strip()
1962
+
1963
+ new_category = request.form.get('category')
1964
+ new_subcategory = request.form.get('subcategory', 'Без подкатегории').strip()
1965
+
1966
+ if new_category != 'Без категории' and not any(c['name'] == new_category for c in categories):
1967
+ new_category = 'Без категории'
1968
+ new_subcategory = 'Без подкатегории'
1969
+ flash("Выбранная категория не найдена. Установлено 'Без категории' и 'Без подкатегории'.", 'warning')
1970
+
1971
+ product_to_edit['category'] = new_category
1972
+ product_to_edit['subcategory'] = new_subcategory
1973
+
1974
  product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
1975
  product_to_edit['in_stock'] = 'in_stock' in request.form
1976
  product_to_edit['is_top'] = 'is_top' in request.form
 
1983
  type_str = type_str.strip()
1984
  value_str = value_str.strip().replace(',', '.')
1985
  if not type_str or not value_str:
1986
+ continue
1987
 
1988
  try:
1989
  price_value = round(float(value_str), 2)
 
2133
 
2134
  current_data = load_data()
2135
  display_products = sorted(current_data.get('products', []), key=lambda p: p.get('name', '').lower())
2136
+ display_categories_data = current_data.get('categories', [])
2137
 
2138
  return render_template_string(
2139
  ADMIN_TEMPLATE,
2140
  products=display_products,
2141
+ categories_data=display_categories_data,
2142
  repo_id=REPO_ID,
2143
  currency_code=CURRENCY_CODE
2144
  )