Kgshop commited on
Commit
b1c7292
·
verified ·
1 Parent(s): d54790b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +167 -23
app.py CHANGED
@@ -132,6 +132,8 @@ def load_data():
132
  for product in data['products']:
133
  if 'product_id' not in product:
134
  product['product_id'] = uuid4().hex
 
 
135
 
136
  if not os.path.exists(DATA_FILE):
137
  try:
@@ -195,12 +197,16 @@ CATALOG_TEMPLATE = '''
195
  .product-info { flex-grow: 1; display: flex; flex-direction: column; justify-content: space-between; min-width: 0; padding: 5px 0; }
196
  .product-title { font-size: 0.95rem; font-weight: 600; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
197
  .product-desc { font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
198
- .product-bottom { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
 
199
  .product-price { font-weight: 700; font-size: 1rem; color: var(--primary); }
 
200
  .quantity-control { display: flex; align-items: center; background: var(--bg); border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
201
  .quantity-control button { border: none; background: transparent; width: 32px; height: 32px; font-size: 1.1rem; cursor: pointer; color: var(--primary); display: flex; align-items: center; justify-content: center; transition: background 0.2s; }
202
  .quantity-control button:active { background: #e0e0e0; }
203
  .quantity-control input { width: 36px; height: 32px; border: none; text-align: center; background: transparent; font-weight: 600; font-size: 0.95rem; pointer-events: none; color: var(--primary); }
 
 
204
 
205
  .cart-bar { position: fixed; bottom: 0; left: 0; width: 100%; background: var(--surface); box-shadow: 0 -4px 20px rgba(0,0,0,0.06); padding: 15px 20px calc(15px + env(safe-area-inset-bottom)); display: none; justify-content: space-between; align-items: center; z-index: 100; border-top-left-radius: 20px; border-top-right-radius: 20px; }
206
  .cart-info { display: flex; flex-direction: column; }
@@ -221,9 +227,15 @@ CATALOG_TEMPLATE = '''
221
  .customer-form input:focus { border-color: var(--primary); background: var(--surface); }
222
 
223
  .cart-item-list { display: flex; flex-direction: column; gap: 15px; margin-bottom: 25px; }
224
- .cart-item { display: flex; justify-content: space-between; align-items: center; background: var(--bg); padding: 15px; border-radius: 12px; }
225
- .cart-item-name { flex-grow: 1; font-size: 0.95rem; font-weight: 500; line-height: 1.3; margin-right: 15px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
226
- .cart-item-price { font-weight: 700; white-space: nowrap; color: var(--primary); }
 
 
 
 
 
 
227
  .confirm-btn { background: var(--accent); color: #fff; width: 100%; padding: 16px; border: none; border-radius: 14px; font-size: 1.1rem; font-weight: 700; cursor: pointer; box-shadow: 0 4px 15px rgba(37,211,102,0.3); }
228
 
229
  .gallery-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; z-index: 300; justify-content: center; align-items: center; flex-direction: column; }
@@ -391,8 +403,19 @@ CATALOG_TEMPLATE = '''
391
  }
392
  }
393
 
 
 
 
 
 
 
 
 
 
 
394
  function renderProductCard(p, container) {
395
  const qty = cart[p.product_id] ? cart[p.product_id].quantity : 0;
 
396
  const hasPhotos = p.photos && p.photos.length > 0;
397
  const photoUrl = hasPhotos
398
  ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p.photos[0]}`
@@ -401,6 +424,8 @@ CATALOG_TEMPLATE = '''
401
  const photoIndicator = hasPhotos && p.photos.length > 1 ? `<div class="photo-count"><i class="fas fa-images"></i> ${p.photos.length}</div>` : '';
402
  const imgClick = hasPhotos ? `onclick="openGallery('${p.product_id}')"` : '';
403
  const descHtml = p.description ? `<div class="product-desc">${p.description}</div>` : '';
 
 
404
 
405
  const div = document.createElement('div');
406
  div.className = 'product-card';
@@ -413,13 +438,17 @@ CATALOG_TEMPLATE = '''
413
  <div>
414
  <div class="product-title">${p.name}</div>
415
  ${descHtml}
 
416
  </div>
417
  <div class="product-bottom">
418
  <div class="product-price">${p.price} ${currency}</div>
419
- <div class="quantity-control">
420
- <button onclick="updateCart('${p.product_id}', -1)"><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
421
- <input type="text" id="qty-${p.product_id}" value="${qty}" readonly>
422
- <button onclick="updateCart('${p.product_id}', 1)"><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
 
 
 
423
  </div>
424
  </div>
425
  </div>
@@ -444,7 +473,7 @@ CATALOG_TEMPLATE = '''
444
  }
445
  }
446
 
447
- function updateCart(productId, change) {
448
  const product = products.find(p => p.product_id === productId);
449
  if (!product) return;
450
 
@@ -452,13 +481,19 @@ CATALOG_TEMPLATE = '''
452
  cart[productId] = { ...product, quantity: 0 };
453
  }
454
 
455
- cart[productId].quantity += change;
 
 
 
 
456
 
457
  if (cart[productId].quantity <= 0) {
458
  delete cart[productId];
459
- document.getElementById(`qty-${productId}`).value = 0;
 
460
  } else {
461
- document.getElementById(`qty-${productId}`).value = cart[productId].quantity;
 
462
  }
463
 
464
  updateCartUI();
@@ -478,21 +513,43 @@ CATALOG_TEMPLATE = '''
478
  cartBar.style.display = 'none';
479
  closeCartModal();
480
  }
 
 
 
 
481
  }
482
 
483
- function openCartModal() {
484
  const list = document.getElementById('cartItemList');
485
  list.innerHTML = '';
486
 
487
  for (let id in cart) {
488
  const item = cart[id];
 
 
 
489
  list.innerHTML += `
490
  <div class="cart-item">
491
- <div class="cart-item-name">${item.name}</div>
492
- <div class="cart-item-price">${item.quantity} x ${item.price} ${currency}</div>
 
 
 
 
 
 
 
 
 
 
 
493
  </div>
494
  `;
495
  }
 
 
 
 
496
  const modal = document.getElementById('cartModal');
497
  modal.style.display = 'flex';
498
  setTimeout(() => modal.classList.add('active'), 10);
@@ -617,7 +674,7 @@ ORDER_TEMPLATE = '''
617
  <title>Накладная №{{ order.id }}</title>
618
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
619
  <style>
620
- :root { --bg: #f4f6f8; --surface: #ffffff; --text: #2d3436; --border: #dfe6e9; --wa: #25D366; --print: #1e272e; }
621
  * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
622
  body { margin: 0; padding: max(20px, env(safe-area-inset-top)) 20px calc(100px + env(safe-area-inset-bottom)); background: var(--bg); display: flex; flex-direction: column; align-items: center; color: var(--text); }
623
  .invoice-box { background: var(--surface); width: 100%; max-width: 900px; padding: 30px; box-shadow: 0 4px 20px rgba(0,0,0,0.05); border-radius: 16px; }
@@ -628,13 +685,20 @@ ORDER_TEMPLATE = '''
628
  .customer-details span { font-weight: 600; color: #1a1a1a; }
629
 
630
  .table-responsive { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin-bottom: 20px; border-radius: 8px; border: 1px solid var(--border); }
631
- table { width: 100%; border-collapse: collapse; min-width: 500px; }
632
  th, td { border-bottom: 1px solid var(--border); padding: 12px; text-align: center; font-size: 0.95rem; }
633
  th { background: #fafafa; font-weight: 700; color: #636e72; text-transform: uppercase; font-size: 0.8rem; letter-spacing: 0.5px; }
634
  .img-cell img { width: 45px; height: 45px; object-fit: cover; border-radius: 8px; border: 1px solid #eee; }
635
  .total-row { background: #fafafa; font-weight: 800; }
636
  .total-row td { font-size: 1.1rem; border-bottom: none; }
637
 
 
 
 
 
 
 
 
638
  .action-bar { position: fixed; bottom: 0; left: 0; width: 100%; background: var(--surface); box-shadow: 0 -4px 20px rgba(0,0,0,0.08); padding: 15px 20px calc(15px + env(safe-area-inset-bottom)); display: flex; gap: 15px; z-index: 100; justify-content: center; border-top-left-radius: 20px; border-top-right-radius: 20px; }
639
  .action-bar-inner { display: flex; gap: 15px; width: 100%; max-width: 900px; }
640
  .btn { flex: 1; padding: 15px 10px; border-radius: 12px; border: none; font-size: 1rem; font-weight: 700; cursor: pointer; color: #fff; display: flex; align-items: center; justify-content: center; gap: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); transition: transform 0.2s; white-space: nowrap; }
@@ -643,13 +707,15 @@ ORDER_TEMPLATE = '''
643
  .btn-print { background: var(--print); }
644
  .btn-home { background: #0984e3; box-shadow: 0 4px 15px rgba(9,132,227,0.3); flex: 0 0 auto; padding: 15px 20px; }
645
 
 
 
646
  @media print {
647
  body { background: #fff; padding: 0; }
648
  .invoice-box { box-shadow: none; padding: 0; max-width: 100%; border-radius: 0; }
649
  .table-responsive { border: none; overflow: visible; }
650
  table { min-width: 100%; }
651
  th, td { border: 1px solid #000; }
652
- .action-bar { display: none; }
653
  }
654
  @media (max-width: 600px) {
655
  .header h1 { font-size: 1.4rem; }
@@ -661,6 +727,7 @@ ORDER_TEMPLATE = '''
661
  </style>
662
  </head>
663
  <body>
 
664
  <div class="invoice-box">
665
  <div style="text-align: center; margin-bottom: 25px;">
666
  <img src="{{ logo_url }}" style="max-height: 80px; max-width: 100%; object-fit: contain;">
@@ -691,23 +758,43 @@ ORDER_TEMPLATE = '''
691
  <th style="text-align: left;">Наименование</th>
692
  <th>Фото</th>
693
  <th>Кол-во</th>
 
694
  <th>Цена</th>
695
  <th>Сумма</th>
696
  </tr>
697
  </thead>
698
  <tbody>
699
  {% for item in order.cart %}
 
 
 
700
  <tr>
701
  <td>{{ loop.index }}</td>
702
- <td style="text-align: left; font-weight: 500;">{{ item.name }}</td>
 
 
 
 
 
 
 
 
 
703
  <td class="img-cell"><img src="{{ item.photo_url }}" alt="img"></td>
704
- <td>{{ item.quantity }}</td>
 
 
 
 
 
 
 
705
  <td>{{ item.price }}</td>
706
  <td>{{ item.price * item.quantity }}</td>
707
  </tr>
708
  {% endfor %}
709
  <tr class="total-row">
710
- <td colspan="5" style="text-align: right; padding-right: 20px;">Итого:</td>
711
  <td>{{ order.total_price }} {{ currency_code }}</td>
712
  </tr>
713
  </tbody>
@@ -728,6 +815,28 @@ ORDER_TEMPLATE = '''
728
  let msg = `Здравствуйте! Мой заказ №{{ order.id }}\nНакладная: ${window.location.href}`;
729
  window.open(`https://api.whatsapp.com/send?phone={{ whatsapp_number }}&text=${encodeURIComponent(msg)}`, '_blank');
730
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  </script>
732
  </body>
733
  </html>
@@ -870,6 +979,7 @@ ADMIN_TEMPLATE = '''
870
  <div class="form-row">
871
  <input type="text" name="name" placeholder="Название товара" required autocomplete="off" style="flex:2;">
872
  <input type="number" name="price" placeholder="Цена" required step="0.01" style="flex:1;">
 
873
  </div>
874
  <textarea name="description" placeholder="Описание товара (необязательно)"></textarea>
875
  <div class="file-input-wrapper">
@@ -894,7 +1004,7 @@ ADMIN_TEMPLATE = '''
894
  {% if product.description %}
895
  <span class="product-desc">{{ product.description[:50] }}{{ '...' if product.description|length > 50 else '' }}</span>
896
  {% endif %}
897
- <span class="product-meta">{{ product.price }} {{ currency_code }} • Фото: {{ product.photos|length if product.photos else 0 }}/10</span>
898
  </div>
899
  </div>
900
  <div class="product-actions">
@@ -914,6 +1024,7 @@ ADMIN_TEMPLATE = '''
914
  <div class="form-row">
915
  <input type="text" name="name" value="{{ product.name }}" required autocomplete="off" style="flex:2;">
916
  <input type="number" name="price" value="{{ product.price }}" required step="0.01" style="flex:1;">
 
917
  </div>
918
  <textarea name="description">{{ product.description }}</textarea>
919
  <div class="file-input-wrapper">
@@ -1034,9 +1145,11 @@ def create_order():
1034
  processed_cart = []
1035
  for item in cart_items:
1036
  processed_cart.append({
 
1037
  "name": item['name'],
1038
  "price": float(item['price']),
1039
  "quantity": int(item['quantity']),
 
1040
  "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photos'][0]}" if item.get('photos') else "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PC9zdmc+"
1041
  })
1042
 
@@ -1073,6 +1186,33 @@ def view_order(order_id):
1073
  logo_url=LOGO_URL
1074
  )
1075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1076
  @app.route('/admin', methods=['GET', 'POST'])
1077
  def admin():
1078
  data = load_data()
@@ -1100,6 +1240,7 @@ def admin():
1100
  elif action == 'add_product':
1101
  name = request.form.get('name', '').strip()
1102
  price = float(request.form.get('price', 0))
 
1103
  description = request.form.get('description', '').strip()
1104
  category = request.form.get('category')
1105
  uploaded_photos = request.files.getlist('photos')[:10]
@@ -1136,6 +1277,7 @@ def admin():
1136
  'product_id': uuid4().hex,
1137
  'name': name,
1138
  'price': price,
 
1139
  'description': description,
1140
  'category': category,
1141
  'photos': photos_list
@@ -1148,6 +1290,7 @@ def admin():
1148
  pid = request.form.get('product_id')
1149
  name = request.form.get('name', '').strip()
1150
  price = float(request.form.get('price', 0))
 
1151
  description = request.form.get('description', '').strip()
1152
  uploaded_photos = request.files.getlist('photos')[:10]
1153
 
@@ -1183,6 +1326,7 @@ def admin():
1183
  if p.get('product_id') == pid:
1184
  p['name'] = name
1185
  p['price'] = price
 
1186
  p['description'] = description
1187
  if photos_list:
1188
  p['photos'] = photos_list
@@ -1223,4 +1367,4 @@ if __name__ == '__main__':
1223
  threading.Thread(target=periodic_backup, daemon=True).start()
1224
 
1225
  port = int(os.environ.get('PORT', 7860))
1226
- app.run(host='0.0.0.0', port=port)
 
132
  for product in data['products']:
133
  if 'product_id' not in product:
134
  product['product_id'] = uuid4().hex
135
+ if 'pieces_per_box' not in product:
136
+ product['pieces_per_box'] = 1
137
 
138
  if not os.path.exists(DATA_FILE):
139
  try:
 
197
  .product-info { flex-grow: 1; display: flex; flex-direction: column; justify-content: space-between; min-width: 0; padding: 5px 0; }
198
  .product-title { font-size: 0.95rem; font-weight: 600; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
199
  .product-desc { font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
200
+ .product-box-info { font-size: 0.8rem; color: #00b894; margin-top: 4px; font-weight: 600; }
201
+ .product-bottom { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; flex-wrap: wrap; gap: 10px; }
202
  .product-price { font-weight: 700; font-size: 1rem; color: var(--primary); }
203
+ .controls-wrapper { display: flex; gap: 8px; align-items: center; }
204
  .quantity-control { display: flex; align-items: center; background: var(--bg); border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
205
  .quantity-control button { border: none; background: transparent; width: 32px; height: 32px; font-size: 1.1rem; cursor: pointer; color: var(--primary); display: flex; align-items: center; justify-content: center; transition: background 0.2s; }
206
  .quantity-control button:active { background: #e0e0e0; }
207
  .quantity-control input { width: 36px; height: 32px; border: none; text-align: center; background: transparent; font-weight: 600; font-size: 0.95rem; pointer-events: none; color: var(--primary); }
208
+ .box-btn { background: var(--primary); color: #fff; border: none; border-radius: 8px; padding: 0 10px; height: 32px; font-size: 0.8rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
209
+ .box-btn:active { opacity: 0.8; }
210
 
211
  .cart-bar { position: fixed; bottom: 0; left: 0; width: 100%; background: var(--surface); box-shadow: 0 -4px 20px rgba(0,0,0,0.06); padding: 15px 20px calc(15px + env(safe-area-inset-bottom)); display: none; justify-content: space-between; align-items: center; z-index: 100; border-top-left-radius: 20px; border-top-right-radius: 20px; }
212
  .cart-info { display: flex; flex-direction: column; }
 
227
  .customer-form input:focus { border-color: var(--primary); background: var(--surface); }
228
 
229
  .cart-item-list { display: flex; flex-direction: column; gap: 15px; margin-bottom: 25px; }
230
+ .cart-item { display: flex; justify-content: space-between; align-items: center; background: var(--bg); padding: 15px; border-radius: 12px; flex-wrap: wrap; gap: 10px; }
231
+ .cart-item-name { flex: 1; min-width: 120px; font-size: 0.95rem; font-weight: 500; line-height: 1.3; }
232
+ .cart-item-controls { display: flex; align-items: center; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); overflow: hidden; }
233
+ .cart-item-controls button { border: none; background: transparent; width: 30px; height: 30px; font-size: 1rem; cursor: pointer; color: var(--primary); }
234
+ .cart-item-controls button:active { background: #e0e0e0; }
235
+ .cart-item-controls span { width: 35px; text-align: center; font-weight: 600; font-size: 0.9rem; }
236
+ .cart-item-price { font-weight: 700; color: var(--primary); min-width: 70px; text-align: right; }
237
+ .cart-item-delete { color: #ff7675; background: none; border: none; font-size: 1.1rem; cursor: pointer; padding: 5px; }
238
+
239
  .confirm-btn { background: var(--accent); color: #fff; width: 100%; padding: 16px; border: none; border-radius: 14px; font-size: 1.1rem; font-weight: 700; cursor: pointer; box-shadow: 0 4px 15px rgba(37,211,102,0.3); }
240
 
241
  .gallery-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; z-index: 300; justify-content: center; align-items: center; flex-direction: column; }
 
403
  }
404
  }
405
 
406
+ function formatQtyText(qty, ppb) {
407
+ ppb = parseInt(ppb) || 1;
408
+ if (ppb > 1 && qty >= ppb) {
409
+ let boxes = Math.floor(qty / ppb);
410
+ let remainder = qty % ppb;
411
+ return `${boxes} кор.` + (remainder > 0 ? ` ${remainder} шт.` : '');
412
+ }
413
+ return `${qty} шт.`;
414
+ }
415
+
416
  function renderProductCard(p, container) {
417
  const qty = cart[p.product_id] ? cart[p.product_id].quantity : 0;
418
+ const ppb = parseInt(p.pieces_per_box) || 1;
419
  const hasPhotos = p.photos && p.photos.length > 0;
420
  const photoUrl = hasPhotos
421
  ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p.photos[0]}`
 
424
  const photoIndicator = hasPhotos && p.photos.length > 1 ? `<div class="photo-count"><i class="fas fa-images"></i> ${p.photos.length}</div>` : '';
425
  const imgClick = hasPhotos ? `onclick="openGallery('${p.product_id}')"` : '';
426
  const descHtml = p.description ? `<div class="product-desc">${p.description}</div>` : '';
427
+ const boxInfoHtml = ppb > 1 ? `<div class="product-box-info">В коробке: ${ppb} шт</div>` : '';
428
+ const addBoxBtn = ppb > 1 ? `<button class="box-btn" onclick="updateCart('${p.product_id}', ${ppb})">+ Коробка</button>` : '';
429
 
430
  const div = document.createElement('div');
431
  div.className = 'product-card';
 
438
  <div>
439
  <div class="product-title">${p.name}</div>
440
  ${descHtml}
441
+ ${boxInfoHtml}
442
  </div>
443
  <div class="product-bottom">
444
  <div class="product-price">${p.price} ${currency}</div>
445
+ <div class="controls-wrapper">
446
+ ${addBoxBtn}
447
+ <div class="quantity-control">
448
+ <button onclick="updateCart('${p.product_id}', -1)"><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
449
+ <input type="text" id="qty-${p.product_id}" value="${qty}" readonly>
450
+ <button onclick="updateCart('${p.product_id}', 1)"><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
451
+ </div>
452
  </div>
453
  </div>
454
  </div>
 
473
  }
474
  }
475
 
476
+ function updateCart(productId, change, exactValue = null) {
477
  const product = products.find(p => p.product_id === productId);
478
  if (!product) return;
479
 
 
481
  cart[productId] = { ...product, quantity: 0 };
482
  }
483
 
484
+ if (exactValue !== null) {
485
+ cart[productId].quantity = exactValue;
486
+ } else {
487
+ cart[productId].quantity += change;
488
+ }
489
 
490
  if (cart[productId].quantity <= 0) {
491
  delete cart[productId];
492
+ const qtyInput = document.getElementById(`qty-${productId}`);
493
+ if (qtyInput) qtyInput.value = 0;
494
  } else {
495
+ const qtyInput = document.getElementById(`qty-${productId}`);
496
+ if (qtyInput) qtyInput.value = cart[productId].quantity;
497
  }
498
 
499
  updateCartUI();
 
513
  cartBar.style.display = 'none';
514
  closeCartModal();
515
  }
516
+
517
+ if (document.getElementById('cartModal').classList.contains('active')) {
518
+ renderCartModalItems();
519
+ }
520
  }
521
 
522
+ function renderCartModalItems() {
523
  const list = document.getElementById('cartItemList');
524
  list.innerHTML = '';
525
 
526
  for (let id in cart) {
527
  const item = cart[id];
528
+ const ppb = parseInt(item.pieces_per_box) || 1;
529
+ const formattedQty = formatQtyText(item.quantity, ppb);
530
+
531
  list.innerHTML += `
532
  <div class="cart-item">
533
+ <div class="cart-item-name">
534
+ ${item.name}
535
+ <div style="font-size: 0.8rem; color: #00b894; margin-top:2px;">${formattedQty}</div>
536
+ </div>
537
+ <div style="display:flex; align-items:center; gap: 10px;">
538
+ <div class="cart-item-controls">
539
+ <button onclick="updateCart('${id}', -1)"><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
540
+ <span>${item.quantity}</span>
541
+ <button onclick="updateCart('${id}', 1)"><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
542
+ </div>
543
+ <button class="cart-item-delete" onclick="updateCart('${id}', 0, 0)"><i class="fas fa-trash-alt"></i></button>
544
+ </div>
545
+ <div class="cart-item-price">${item.price * item.quantity} ${currency}</div>
546
  </div>
547
  `;
548
  }
549
+ }
550
+
551
+ function openCartModal() {
552
+ renderCartModalItems();
553
  const modal = document.getElementById('cartModal');
554
  modal.style.display = 'flex';
555
  setTimeout(() => modal.classList.add('active'), 10);
 
674
  <title>Накладная №{{ order.id }}</title>
675
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
676
  <style>
677
+ :root { --bg: #f4f6f8; --surface: #ffffff; --text: #2d3436; --border: #dfe6e9; --wa: #25D366; --print: #1e272e; --primary: #1a1a1a; }
678
  * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
679
  body { margin: 0; padding: max(20px, env(safe-area-inset-top)) 20px calc(100px + env(safe-area-inset-bottom)); background: var(--bg); display: flex; flex-direction: column; align-items: center; color: var(--text); }
680
  .invoice-box { background: var(--surface); width: 100%; max-width: 900px; padding: 30px; box-shadow: 0 4px 20px rgba(0,0,0,0.05); border-radius: 16px; }
 
685
  .customer-details span { font-weight: 600; color: #1a1a1a; }
686
 
687
  .table-responsive { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin-bottom: 20px; border-radius: 8px; border: 1px solid var(--border); }
688
+ table { width: 100%; border-collapse: collapse; min-width: 600px; }
689
  th, td { border-bottom: 1px solid var(--border); padding: 12px; text-align: center; font-size: 0.95rem; }
690
  th { background: #fafafa; font-weight: 700; color: #636e72; text-transform: uppercase; font-size: 0.8rem; letter-spacing: 0.5px; }
691
  .img-cell img { width: 45px; height: 45px; object-fit: cover; border-radius: 8px; border: 1px solid #eee; }
692
  .total-row { background: #fafafa; font-weight: 800; }
693
  .total-row td { font-size: 1.1rem; border-bottom: none; }
694
 
695
+ .qty-info { font-size: 0.8rem; color: #00b894; font-weight: 600; margin-top: 4px; display: block; }
696
+
697
+ .action-btns { display: flex; align-items: center; justify-content: center; gap: 5px; }
698
+ .edit-btn { background: #f1f2f6; border: 1px solid var(--border); border-radius: 6px; width: 28px; height: 28px; cursor: pointer; color: var(--primary); display: flex; align-items: center; justify-content: center; }
699
+ .edit-btn:active { background: #dfe6e9; }
700
+ .delete-btn { color: #ff7675; }
701
+
702
  .action-bar { position: fixed; bottom: 0; left: 0; width: 100%; background: var(--surface); box-shadow: 0 -4px 20px rgba(0,0,0,0.08); padding: 15px 20px calc(15px + env(safe-area-inset-bottom)); display: flex; gap: 15px; z-index: 100; justify-content: center; border-top-left-radius: 20px; border-top-right-radius: 20px; }
703
  .action-bar-inner { display: flex; gap: 15px; width: 100%; max-width: 900px; }
704
  .btn { flex: 1; padding: 15px 10px; border-radius: 12px; border: none; font-size: 1rem; font-weight: 700; cursor: pointer; color: #fff; display: flex; align-items: center; justify-content: center; gap: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); transition: transform 0.2s; white-space: nowrap; }
 
707
  .btn-print { background: var(--print); }
708
  .btn-home { background: #0984e3; box-shadow: 0 4px 15px rgba(9,132,227,0.3); flex: 0 0 auto; padding: 15px 20px; }
709
 
710
+ #loadingOverlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.8); z-index: 999; justify-content: center; align-items: center; font-size: 2rem; color: var(--primary); }
711
+
712
  @media print {
713
  body { background: #fff; padding: 0; }
714
  .invoice-box { box-shadow: none; padding: 0; max-width: 100%; border-radius: 0; }
715
  .table-responsive { border: none; overflow: visible; }
716
  table { min-width: 100%; }
717
  th, td { border: 1px solid #000; }
718
+ .action-bar, .no-print { display: none !important; }
719
  }
720
  @media (max-width: 600px) {
721
  .header h1 { font-size: 1.4rem; }
 
727
  </style>
728
  </head>
729
  <body>
730
+ <div id="loadingOverlay"><i class="fas fa-spinner fa-spin"></i></div>
731
  <div class="invoice-box">
732
  <div style="text-align: center; margin-bottom: 25px;">
733
  <img src="{{ logo_url }}" style="max-height: 80px; max-width: 100%; object-fit: contain;">
 
758
  <th style="text-align: left;">Наименование</th>
759
  <th>Фото</th>
760
  <th>Кол-во</th>
761
+ <th class="no-print">Действия</th>
762
  <th>Цена</th>
763
  <th>Сумма</th>
764
  </tr>
765
  </thead>
766
  <tbody>
767
  {% for item in order.cart %}
768
+ {% set ppb = item.pieces_per_box|default(1)|int %}
769
+ {% set boxes = (item.quantity / ppb)|round(0, 'floor')|int %}
770
+ {% set remainder = item.quantity % ppb %}
771
  <tr>
772
  <td>{{ loop.index }}</td>
773
+ <td style="text-align: left; font-weight: 500;">
774
+ {{ item.name }}
775
+ <span class="qty-info">
776
+ {% if ppb > 1 and boxes > 0 %}
777
+ {{ boxes }} кор.{% if remainder > 0 %} {{ remainder }} шт.{% endif %}
778
+ {% else %}
779
+ {{ item.quantity }} шт.
780
+ {% endif %}
781
+ </span>
782
+ </td>
783
  <td class="img-cell"><img src="{{ item.photo_url }}" alt="img"></td>
784
+ <td style="font-weight: bold;">{{ item.quantity }}</td>
785
+ <td class="no-print">
786
+ <div class="action-btns">
787
+ <button class="edit-btn" onclick="updateItem('{{ item.product_id }}', -1)"><i class="fas fa-minus" style="font-size:0.7rem;"></i></button>
788
+ <button class="edit-btn" onclick="updateItem('{{ item.product_id }}', 1)"><i class="fas fa-plus" style="font-size:0.7rem;"></i></button>
789
+ <button class="edit-btn delete-btn" onclick="updateItem('{{ item.product_id }}', 0, true)"><i class="fas fa-trash"></i></button>
790
+ </div>
791
+ </td>
792
  <td>{{ item.price }}</td>
793
  <td>{{ item.price * item.quantity }}</td>
794
  </tr>
795
  {% endfor %}
796
  <tr class="total-row">
797
+ <td colspan="6" style="text-align: right; padding-right: 20px;">Итого:</td>
798
  <td>{{ order.total_price }} {{ currency_code }}</td>
799
  </tr>
800
  </tbody>
 
815
  let msg = `Здравствуйте! Мой заказ №{{ order.id }}\nНакладная: ${window.location.href}`;
816
  window.open(`https://api.whatsapp.com/send?phone={{ whatsapp_number }}&text=${encodeURIComponent(msg)}`, '_blank');
817
  }
818
+
819
+ function updateItem(productId, change, isRemove = false) {
820
+ document.getElementById('loadingOverlay').style.display = 'flex';
821
+ fetch(`/edit_order/{{ order.id }}`, {
822
+ method: 'POST',
823
+ headers: { 'Content-Type': 'application/json' },
824
+ body: JSON.stringify({ product_id: productId, change: change, remove: isRemove })
825
+ })
826
+ .then(r => r.json())
827
+ .then(data => {
828
+ if(data.success) {
829
+ window.location.reload();
830
+ } else {
831
+ alert('Ошибка обновления');
832
+ document.getElementById('loadingOverlay').style.display = 'none';
833
+ }
834
+ })
835
+ .catch(() => {
836
+ alert('Произошла ошибка');
837
+ document.getElementById('loadingOverlay').style.display = 'none';
838
+ });
839
+ }
840
  </script>
841
  </body>
842
  </html>
 
979
  <div class="form-row">
980
  <input type="text" name="name" placeholder="Название товара" required autocomplete="off" style="flex:2;">
981
  <input type="number" name="price" placeholder="Цена" required step="0.01" style="flex:1;">
982
+ <input type="number" name="pieces_per_box" placeholder="В коробке (шт)" value="1" min="1" required style="flex:1;">
983
  </div>
984
  <textarea name="description" placeholder="Описание товара (необязательно)"></textarea>
985
  <div class="file-input-wrapper">
 
1004
  {% if product.description %}
1005
  <span class="product-desc">{{ product.description[:50] }}{{ '...' if product.description|length > 50 else '' }}</span>
1006
  {% endif %}
1007
+ <span class="product-meta">{{ product.price }} {{ currency_code }} • В коробке: {{ product.pieces_per_box|default(1) }} шт • Фото: {{ product.photos|length if product.photos else 0 }}/10</span>
1008
  </div>
1009
  </div>
1010
  <div class="product-actions">
 
1024
  <div class="form-row">
1025
  <input type="text" name="name" value="{{ product.name }}" required autocomplete="off" style="flex:2;">
1026
  <input type="number" name="price" value="{{ product.price }}" required step="0.01" style="flex:1;">
1027
+ <input type="number" name="pieces_per_box" value="{{ product.pieces_per_box|default(1) }}" min="1" required style="flex:1;">
1028
  </div>
1029
  <textarea name="description">{{ product.description }}</textarea>
1030
  <div class="file-input-wrapper">
 
1145
  processed_cart = []
1146
  for item in cart_items:
1147
  processed_cart.append({
1148
+ "product_id": item.get('product_id'),
1149
  "name": item['name'],
1150
  "price": float(item['price']),
1151
  "quantity": int(item['quantity']),
1152
+ "pieces_per_box": int(item.get('pieces_per_box', 1)),
1153
  "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photos'][0]}" if item.get('photos') else "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PC9zdmc+"
1154
  })
1155
 
 
1186
  logo_url=LOGO_URL
1187
  )
1188
 
1189
+ @app.route('/edit_order/<order_id>', methods=['POST'])
1190
+ def edit_order(order_id):
1191
+ data = load_data()
1192
+ order = data.get('orders', {}).get(order_id)
1193
+ if not order:
1194
+ return jsonify({"success": False, "error": "Order not found"}), 404
1195
+
1196
+ req_data = request.get_json()
1197
+ product_id = req_data.get('product_id')
1198
+ change = req_data.get('change', 0)
1199
+ remove = req_data.get('remove', False)
1200
+
1201
+ for item in order['cart']:
1202
+ if item.get('product_id') == product_id:
1203
+ if remove:
1204
+ order['cart'].remove(item)
1205
+ else:
1206
+ item['quantity'] += change
1207
+ if item['quantity'] <= 0:
1208
+ order['cart'].remove(item)
1209
+ break
1210
+
1211
+ order['total_price'] = sum(float(i['price']) * int(i['quantity']) for i in order['cart'])
1212
+ save_data(data)
1213
+
1214
+ return jsonify({"success": True, "total_price": order['total_price']})
1215
+
1216
  @app.route('/admin', methods=['GET', 'POST'])
1217
  def admin():
1218
  data = load_data()
 
1240
  elif action == 'add_product':
1241
  name = request.form.get('name', '').strip()
1242
  price = float(request.form.get('price', 0))
1243
+ pieces_per_box = int(request.form.get('pieces_per_box', 1))
1244
  description = request.form.get('description', '').strip()
1245
  category = request.form.get('category')
1246
  uploaded_photos = request.files.getlist('photos')[:10]
 
1277
  'product_id': uuid4().hex,
1278
  'name': name,
1279
  'price': price,
1280
+ 'pieces_per_box': pieces_per_box,
1281
  'description': description,
1282
  'category': category,
1283
  'photos': photos_list
 
1290
  pid = request.form.get('product_id')
1291
  name = request.form.get('name', '').strip()
1292
  price = float(request.form.get('price', 0))
1293
+ pieces_per_box = int(request.form.get('pieces_per_box', 1))
1294
  description = request.form.get('description', '').strip()
1295
  uploaded_photos = request.files.getlist('photos')[:10]
1296
 
 
1326
  if p.get('product_id') == pid:
1327
  p['name'] = name
1328
  p['price'] = price
1329
+ p['pieces_per_box'] = pieces_per_box
1330
  p['description'] = description
1331
  if photos_list:
1332
  p['photos'] = photos_list
 
1367
  threading.Thread(target=periodic_backup, daemon=True).start()
1368
 
1369
  port = int(os.environ.get('PORT', 7860))
1370
+ app.run(host='0.0.0.0', port=port)