Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| import os | |
| from flask import Flask, request, Response, render_template_string, jsonify, redirect, url_for | |
| import hmac | |
| import hashlib | |
| import json | |
| from urllib.parse import unquote, parse_qs, quote | |
| import time | |
| from datetime import datetime | |
| import threading | |
| import random | |
| import re | |
| import pytz | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from huggingface_hub.utils import RepositoryNotFoundError | |
| BOT_TOKEN = os.getenv("BOT_TOKEN", "7835463659:AAGNePbelZIAOeaglyQi1qulOqnjs4BGQn4") | |
| HOST = '0.0.0.0' | |
| PORT = 7860 | |
| DATA_FILE = 'data.json' | |
| ORG_INFO_FILE = 'organization_info.json' | |
| REPO_ID = "flpolprojects/examplebonus" | |
| HF_DATA_FILE_PATH = "data.json" | |
| HF_ORG_INFO_FILE_PATH = "organization_info.json" | |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE") | |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") | |
| BISHKEK_TZ = pytz.timezone('Asia/Bishkek') | |
| app = Flask(__name__) | |
| app.secret_key = os.urandom(24) | |
| _data_lock = threading.Lock() | |
| _org_info_lock = threading.Lock() | |
| visitor_data_cache = {} | |
| org_info_cache = {} | |
| def generate_unique_id(all_data): | |
| while True: | |
| new_id = str(random.randint(10000, 99999)) | |
| if new_id not in all_data: | |
| return new_id | |
| def download_files_from_hf(): | |
| global visitor_data_cache, org_info_cache | |
| if not HF_TOKEN_READ: | |
| return | |
| try: | |
| hf_hub_download(repo_id=REPO_ID, filename=HF_DATA_FILE_PATH, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, force_download=True, etag_timeout=10) | |
| with _data_lock: | |
| try: | |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: | |
| visitor_data_cache = json.load(f) | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| visitor_data_cache = {} | |
| except Exception: | |
| pass | |
| try: | |
| hf_hub_download(repo_id=REPO_ID, filename=HF_ORG_INFO_FILE_PATH, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, force_download=True, etag_timeout=10) | |
| with _org_info_lock: | |
| try: | |
| with open(ORG_INFO_FILE, 'r', encoding='utf-8') as f: | |
| org_info_cache = json.load(f) | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| org_info_cache = {} | |
| except Exception: | |
| pass | |
| def load_visitor_data(): | |
| global visitor_data_cache | |
| with _data_lock: | |
| if not visitor_data_cache: | |
| try: | |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: | |
| visitor_data_cache = json.load(f) | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| visitor_data_cache = {} | |
| return visitor_data_cache | |
| def save_visitor_data(data): | |
| with _data_lock: | |
| visitor_data_cache.update(data) | |
| with open(DATA_FILE, 'w', encoding='utf-8') as f: | |
| json.dump(visitor_data_cache, f, ensure_ascii=False, indent=4) | |
| upload_data_to_hf_async() | |
| def load_org_info(): | |
| global org_info_cache | |
| with _org_info_lock: | |
| if not org_info_cache: | |
| try: | |
| with open(ORG_INFO_FILE, 'r', encoding='utf-8') as f: | |
| org_info_cache = json.load(f) | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| org_info_cache = { | |
| "name": "Название вашей организации", | |
| "phones": ["+996 (555) 123-456"], | |
| "address": "г. Бишкек, ул. Примерная, 123", | |
| "links": [{"label": "Наш сайт", "url": "https://example.com"}] | |
| } | |
| return org_info_cache | |
| def save_org_info(data): | |
| global org_info_cache | |
| with _org_info_lock: | |
| org_info_cache = data | |
| with open(ORG_INFO_FILE, 'w', encoding='utf-8') as f: | |
| json.dump(org_info_cache, f, ensure_ascii=False, indent=4) | |
| upload_data_to_hf_async(is_org_info=True) | |
| def upload_data_to_hf(is_org_info=False): | |
| if not HF_TOKEN_WRITE: | |
| return | |
| file_to_upload = ORG_INFO_FILE if is_org_info else DATA_FILE | |
| path_in_repo = HF_ORG_INFO_FILE_PATH if is_org_info else HF_DATA_FILE_PATH | |
| commit_msg = f"Update org info {datetime.now(BISHKEK_TZ).strftime('%Y-%m-%d %H:%M:%S')}" if is_org_info else f"Update bonus data {datetime.now(BISHKEK_TZ).strftime('%Y-%m-%d %H:%M:%S')}" | |
| if not os.path.exists(file_to_upload) or os.path.getsize(file_to_upload) == 0: | |
| return | |
| try: | |
| api = HfApi() | |
| api.upload_file( | |
| path_or_fileobj=file_to_upload, | |
| path_in_repo=path_in_repo, | |
| repo_id=REPO_ID, | |
| repo_type="dataset", | |
| token=HF_TOKEN_WRITE, | |
| commit_message=commit_msg | |
| ) | |
| except Exception as e: | |
| # Silently fail for now | |
| pass | |
| def upload_data_to_hf_async(is_org_info=False): | |
| upload_thread = threading.Thread(target=upload_data_to_hf, args=(is_org_info,), daemon=True) | |
| upload_thread.start() | |
| def periodic_backup(): | |
| if not HF_TOKEN_WRITE: | |
| return | |
| while True: | |
| time.sleep(3600) | |
| upload_data_to_hf(is_org_info=False) | |
| time.sleep(5) | |
| upload_data_to_hf(is_org_info=True) | |
| def verify_telegram_data(init_data_str): | |
| try: | |
| parsed_data = parse_qs(init_data_str) | |
| received_hash = parsed_data.pop('hash', [None])[0] | |
| if not received_hash: | |
| return None, False | |
| data_check_list = sorted([(k, v[0]) for k, v in parsed_data.items()]) | |
| data_check_string = "\n".join([f"{k}={v}" for k, v in data_check_list]) | |
| secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest() | |
| calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() | |
| if calculated_hash == received_hash: | |
| return parsed_data, True | |
| return parsed_data, False | |
| except Exception: | |
| return None, False | |
| def clean_phone_number(phone_str): | |
| return re.sub(r'\D', '', phone_str) | |
| # =========== CLIENT-SIDE TEMPLATES =========== | |
| MAIN_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"> | |
| <title>Bonus</title> | |
| <script src="https://telegram.org/js/telegram-web-app.js"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { --brand-yellow: #FFC107; --brand-black: #101010; --brand-red: #F44336; --card-bg: #1c1c1e; --text-color: #ffffff; --text-secondary-color: #a0a0a0; --border-radius: 16px; --padding-m: 16px; --font-family: 'Manrope', sans-serif; } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { background-color: var(--brand-black); font-family: var(--font-family); color: var(--text-color); padding: var(--padding-m); min-height: 100vh; } | |
| body { visibility: hidden; } | |
| .container { max-width: 600px; margin: 0 auto; display: flex; flex-direction: column; gap: var(--padding-m); } | |
| .header { text-align: left; padding: var(--padding-m) 0; } | |
| .logo { font-size: 2.5em; font-weight: 800; color: var(--text-color); letter-spacing: -1px; } | |
| .logo span { color: var(--brand-yellow); } | |
| .welcome-text { font-size: 1em; color: var(--text-secondary-color); margin-top: 4px; } | |
| .card-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--padding-m); } | |
| .bonus-card, .debt-card { background: linear-gradient(145deg, #2a2a2a, #1c1c1c); border-radius: 24px; padding: 24px; text-align: center; border: 1px solid transparent; } | |
| .bonus-card { box-shadow: 0 0 35px rgba(255, 193, 7, 0.15); border-color: rgba(255, 193, 7, 0.2); } | |
| .debt-card { box-shadow: 0 0 35px rgba(244, 67, 54, 0.15); border-color: rgba(244, 67, 54, 0.2); } | |
| .card-label { font-size: 1.1em; font-weight: 500; color: var(--text-secondary-color); margin-bottom: 12px; } | |
| .bonus-amount, .debt-amount { font-size: 3em; font-weight: 800; letter-spacing: -2px; line-height: 1; } | |
| .bonus-amount { color: var(--brand-yellow); } | |
| .debt-amount { color: var(--brand-red); } | |
| .client-id-card { background-color: var(--card-bg); border-radius: var(--border-radius); padding: var(--padding-m); display: flex; justify-content: space-between; align-items: center; } | |
| .client-id-label { font-weight: 500; color: var(--text-secondary-color); } | |
| .client-id-value { font-size: 1.3em; font-weight: 700; color: var(--brand-yellow); letter-spacing: 2px; background-color: rgba(255,193,7,0.1); padding: 4px 10px; border-radius: 8px; } | |
| .nav-buttons { display: flex; flex-direction: column; gap: 12px; margin-top: 8px; } | |
| .nav-btn { background-color: var(--card-bg); color: var(--text-color); border: none; border-radius: var(--border-radius); padding: 20px; font-size: 1.1em; font-weight: 600; text-align: left; width: 100%; cursor: pointer; transition: background-color 0.2s, transform 0.2s; display: flex; justify-content: space-between; align-items: center; } | |
| .nav-btn:hover { background-color: #2c2c2e; } | |
| .nav-btn:active { transform: scale(0.98); } | |
| .nav-btn .arrow { font-size: 1.5em; color: var(--text-secondary-color); } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header class="header"> | |
| <div class="logo">BONUS<span>.</span></div> | |
| <p id="greeting" class="welcome-text">Добро пожаловать!</p> | |
| </header> | |
| <section class="card-grid"> | |
| <div class="bonus-card"> | |
| <p class="card-label">Ваши бонусы</p> | |
| <p class="bonus-amount">{{ "%.2f"|format(user.bonuses|float) }}</p> | |
| </div> | |
| <div class="debt-card"> | |
| <p class="card-label">Ваш долг</p> | |
| <p class="debt-amount">{{ "%.2f"|format(user.debts|float) }}</p> | |
| </div> | |
| </section> | |
| <section class="client-id-card"> | |
| <p class="client-id-label">Ваш ID клиента</p> | |
| <p class="client-id-value">{{ user.id }}</p> | |
| </section> | |
| <nav class="nav-buttons"> | |
| <button onclick="navigate('history')" class="nav-btn">История операций <span class="arrow">›</span></button> | |
| <button onclick="navigate('invoices')" class="nav-btn">Мои накладные <span class="arrow">›</span></button> | |
| <button onclick="navigate('card')" class="nav-btn">Визитка <span class="arrow">›</span></button> | |
| </nav> | |
| </div> | |
| <script> | |
| const tg = window.Telegram.WebApp; | |
| function applyTheme(themeParams) { | |
| const root = document.documentElement; | |
| root.style.setProperty('--brand-black', themeParams.bg_color || '#101010'); | |
| root.style.setProperty('--text-color', themeParams.text_color || '#ffffff'); | |
| root.style.setProperty('--text-secondary-color', themeParams.hint_color || '#a0a0a0'); | |
| root.style.setProperty('--brand-yellow', themeParams.button_color || '#FFC107'); | |
| root.style.setProperty('--card-bg', themeParams.secondary_bg_color || '#1c1c1e'); | |
| } | |
| function setupTelegram() { | |
| if (!tg || !tg.initData) { document.body.style.visibility = 'visible'; return; } | |
| tg.ready(); | |
| tg.expand(); | |
| if (tg.themeParams && Object.keys(tg.themeParams).length > 0) { applyTheme(tg.themeParams); } | |
| tg.onEvent('themeChanged', () => applyTheme(tg.themeParams)); | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const userIdForTest = urlParams.get('user_id_for_test'); | |
| if (!userIdForTest) { | |
| fetch('/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ initData: tg.initData }) }) | |
| .then(response => response.json()) | |
| .then(data => { | |
| if (data.status === 'ok' && data.verified && data.user_id) { | |
| window.location.replace('/?user_id_for_test=' + data.user_id); | |
| } else { document.body.style.visibility = 'visible'; } | |
| }) | |
| .catch(error => { document.body.style.visibility = 'visible'; }); | |
| } else { document.body.style.visibility = 'visible'; } | |
| const user = tg.initDataUnsafe?.user; | |
| const greetingElement = document.getElementById('greeting'); | |
| if (user) { greetingElement.textContent = `Привет, ${user.first_name || user.username || 'Гость'}! 👋`; } | |
| else { greetingElement.textContent = `Привет, {{ user.first_name or 'Гость' }}! 👋`; } | |
| } | |
| function navigate(page) { | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const userId = urlParams.get('user_id_for_test'); | |
| if (userId) { window.location.href = `/?page=${page}&user_id_for_test=${userId}`; } | |
| } | |
| if (window.Telegram && window.Telegram.WebApp) { setupTelegram(); } | |
| else { window.addEventListener('load', setupTelegram, {once: true}); setTimeout(() => { if (document.body.style.visibility !== 'visible') { document.body.style.visibility = 'visible'; } }, 2000); } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| HISTORY_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"> | |
| <title>История операций</title> | |
| <script src="https://telegram.org/js/telegram-web-app.js"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { --brand-yellow: #FFC107; --brand-black: #101010; --brand-red: #F44336; --card-bg: #1c1c1e; --text-color: #ffffff; --text-secondary-color: #a0a0a0; --border-radius: 16px; --padding-m: 16px; --font-family: 'Manrope', sans-serif; } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { background-color: var(--brand-black); font-family: var(--font-family); color: var(--text-color); padding: var(--padding-m); min-height: 100vh; } | |
| .container { max-width: 600px; margin: 0 auto; display: flex; flex-direction: column; gap: var(--padding-m); } | |
| .history-section { background-color: var(--card-bg); border-radius: var(--border-radius); padding: 24px; } | |
| .history-title { font-size: 1.4em; font-weight: 700; margin-bottom: var(--padding-m); padding-bottom: var(--padding-m); border-bottom: 1px solid rgba(255, 255, 255, 0.1); } | |
| .history-list { list-style: none; padding: 0; margin: 0; max-height: 75vh; overflow-y: auto; } | |
| .history-item { display: flex; justify-content: space-between; align-items: center; padding: 14px 4px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } | |
| .history-item:last-child { border-bottom: none; } | |
| .history-details { display: flex; flex-direction: column; } | |
| .history-description { font-size: 1em; font-weight: 500; } | |
| .history-date { font-size: 0.8em; color: var(--text-secondary-color); margin-top: 4px; } | |
| .history-amount { font-size: 1.1em; font-weight: 700; } | |
| .history-amount.accrual { color: #4CAF50; } | |
| .history-amount.deduction { color: #F44336; } | |
| .no-history { text-align: center; color: var(--text-secondary-color); padding: 2rem 0; } | |
| .back-btn { display: inline-block; margin-bottom: 16px; color: var(--brand-yellow); text-decoration: none; font-weight: 600; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <a href="{{ url_for('index', user_id_for_test=user.id) }}" class="back-btn">‹ Назад</a> | |
| <section class="history-section"> | |
| <h2 class="history-title">История операций</h2> | |
| {% if user.combined_history %} | |
| <ul class="history-list"> | |
| {% for item in user.combined_history %} | |
| <li class="history-item"> | |
| <div class="history-details"> | |
| <span class="history-description">{{ item.description }}</span> | |
| <span class="history-date">{{ item.date_str }}</span> | |
| </div> | |
| {% if item.transaction_type == 'bonus' %} | |
| <span class="history-amount {{ 'accrual' if item.type == 'accrual' else 'deduction' }}"> | |
| {{ '+' if item.type == 'accrual' else '-' }}{{ "%.2f"|format(item.amount|float) }} | |
| </span> | |
| {% elif item.transaction_type == 'debt' %} | |
| <span class="history-amount {{ 'deduction' if item.type == 'accrual' else 'accrual' }}"> | |
| {{ '+' if item.type == 'accrual' else '-' }}{{ "%.2f"|format(item.amount|float) }} | |
| </span> | |
| {% endif %} | |
| </li> | |
| {% endfor %} | |
| </ul> | |
| {% else %} | |
| <p class="no-history">Операций пока не было.</p> | |
| {% endif %} | |
| </section> | |
| </div> | |
| <script> | |
| const tg = window.Telegram.WebApp; | |
| tg.ready(); | |
| tg.expand(); | |
| function applyTheme(themeParams) { | |
| const root = document.documentElement; | |
| root.style.setProperty('--brand-black', themeParams.bg_color || '#101010'); | |
| root.style.setProperty('--text-color', themeParams.text_color || '#ffffff'); | |
| root.style.setProperty('--text-secondary-color', themeParams.hint_color || '#a0a0a0'); | |
| root.style.setProperty('--brand-yellow', themeParams.button_color || '#FFC107'); | |
| root.style.setProperty('--card-bg', themeParams.secondary_bg_color || '#1c1c1e'); | |
| } | |
| if (tg.themeParams && Object.keys(tg.themeParams).length > 0) { applyTheme(tg.themeParams); } | |
| tg.onEvent('themeChanged', () => applyTheme(tg.themeParams)); | |
| tg.BackButton.show(); | |
| tg.BackButton.onClick(() => window.location.href = `{{ url_for('index', user_id_for_test=user.id) }}`); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| INVOICES_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"> | |
| <title>Мои накладные</title> | |
| <script src="https://telegram.org/js/telegram-web-app.js"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { --brand-yellow: #FFC107; --brand-black: #101010; --card-bg: #1c1c1e; --text-color: #ffffff; --text-secondary-color: #a0a0a0; --border-radius: 16px; --padding-m: 16px; --font-family: 'Manrope', sans-serif; } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { background-color: var(--brand-black); font-family: var(--font-family); color: var(--text-color); padding: var(--padding-m); min-height: 100vh; } | |
| .container { max-width: 600px; margin: 0 auto; display: flex; flex-direction: column; gap: var(--padding-m); } | |
| .back-btn { display: inline-block; margin-bottom: 16px; color: var(--brand-yellow); text-decoration: none; font-weight: 600; } | |
| .invoices-list { display: flex; flex-direction: column; gap: 12px; } | |
| .invoice-item summary { list-style: none; display: flex; justify-content: space-between; align-items: center; background-color: var(--card-bg); padding: 18px; border-radius: var(--border-radius); cursor: pointer; } | |
| .invoice-item summary::-webkit-details-marker { display: none; } | |
| .invoice-info .date { font-weight: 600; } | |
| .invoice-info .id { font-size: 0.8em; color: var(--text-secondary-color); } | |
| .invoice-total { font-size: 1.2em; font-weight: 700; color: var(--brand-yellow); } | |
| .invoice-details { background-color: #2c2c2e; margin: -10px 6px 6px 6px; padding: 16px; border-radius: 0 0 12px 12px; } | |
| .invoice-details table { width: 100%; border-collapse: collapse; } | |
| .invoice-details th, .invoice-details td { text-align: left; padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.1); } | |
| .invoice-details th { font-weight: 500; color: var(--text-secondary-color); } | |
| .invoice-details td.price { text-align: right; } | |
| .no-invoices { text-align: center; color: var(--text-secondary-color); padding: 3rem 0; background-color: var(--card-bg); border-radius: var(--border-radius); } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <a href="{{ url_for('index', user_id_for_test=user.id) }}" class="back-btn">‹ Назад</a> | |
| <h1 style="font-size: 1.5em; font-weight: 700;">Мои накладные</h1> | |
| <div class="invoices-list"> | |
| {% if user.invoices %} | |
| {% for invoice in user.invoices %} | |
| <details class="invoice-item"> | |
| <summary> | |
| <div class="invoice-info"> | |
| <div class="date">{{ invoice.date_str }}</div> | |
| <div class="id">Накладная #{{ invoice.id }}</div> | |
| </div> | |
| <div class="invoice-total">{{ "%.2f"|format(invoice.total_amount|float) }}</div> | |
| </summary> | |
| <div class="invoice-details"> | |
| <table> | |
| <thead> | |
| <tr><th>Товар</th><th>Кол-во</th><th class="price">Цена</th><th class="price">Сумма</th></tr> | |
| </thead> | |
| <tbody> | |
| {% for item in invoice.items %} | |
| <tr> | |
| <td>{{ item.name }}</td> | |
| <td>{{ item.quantity }}</td> | |
| <td class="price">{{ "%.2f"|format(item.price_per_unit|float) }}</td> | |
| <td class="price">{{ "%.2f"|format(item.total_price|float) }}</td> | |
| </tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| </div> | |
| </details> | |
| {% endfor %} | |
| {% else %} | |
| <p class="no-invoices">У вас пока нет накладных.</p> | |
| {% endif %} | |
| </div> | |
| </div> | |
| <script> | |
| const tg = window.Telegram.WebApp; | |
| tg.ready(); | |
| tg.expand(); | |
| function applyTheme(themeParams) { | |
| const root = document.documentElement; | |
| root.style.setProperty('--brand-black', themeParams.bg_color || '#101010'); | |
| root.style.setProperty('--text-color', themeParams.text_color || '#ffffff'); | |
| root.style.setProperty('--text-secondary-color', themeParams.hint_color || '#a0a0a0'); | |
| root.style.setProperty('--brand-yellow', themeParams.button_color || '#FFC107'); | |
| root.style.setProperty('--card-bg', themeParams.secondary_bg_color || '#1c1c1e'); | |
| } | |
| if (tg.themeParams && Object.keys(tg.themeParams).length > 0) { applyTheme(tg.themeParams); } | |
| tg.onEvent('themeChanged', () => applyTheme(tg.themeParams)); | |
| tg.BackButton.show(); | |
| tg.BackButton.onClick(() => window.location.href = `{{ url_for('index', user_id_for_test=user.id) }}`); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| CARD_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"> | |
| <title>Визитка</title> | |
| <script src="https://telegram.org/js/telegram-web-app.js"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { --brand-yellow: #FFC107; --brand-black: #101010; --card-bg: #1c1c1e; --text-color: #ffffff; --text-secondary-color: #a0a0a0; --border-radius: 16px; --padding-m: 16px; --font-family: 'Manrope', sans-serif; } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { background-color: var(--brand-black); font-family: var(--font-family); color: var(--text-color); padding: var(--padding-m); min-height: 100vh; } | |
| .container { max-width: 600px; margin: 0 auto; } | |
| .back-btn { display: inline-block; margin-bottom: 24px; color: var(--brand-yellow); text-decoration: none; font-weight: 600; } | |
| .card { background-color: var(--card-bg); border-radius: var(--border-radius); padding: 24px; } | |
| .org-name { font-size: 1.8em; font-weight: 700; margin-bottom: 24px; } | |
| .info-block { margin-bottom: 20px; } | |
| .info-label { font-size: 0.9em; color: var(--text-secondary-color); margin-bottom: 8px; } | |
| .info-value { font-size: 1.1em; font-weight: 500; } | |
| .phone-block { margin-bottom: 20px; } | |
| .phone-number { display: flex; align-items: center; justify-content: space-between; font-size: 1.2em; font-weight: 500; margin-bottom: 12px; } | |
| .phone-actions { display: flex; gap: 8px; } | |
| .action-btn { display: block; padding: 8px; border-radius: 8px; background-color: #333; } | |
| .action-btn img { display: block; width: 24px; height: 24px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <a href="{{ url_for('index', user_id_for_test=user.id) }}" class="back-btn">‹ Назад</a> | |
| <div class="card"> | |
| <h1 class="org-name">{{ org_info.name }}</h1> | |
| <div class="info-block"> | |
| <div class="info-label">Номера телефонов</div> | |
| {% for phone in org_info.phones %} | |
| <div class="phone-block"> | |
| <div class="phone-number">{{ phone }}</div> | |
| <div class="phone-actions"> | |
| <a href="tel:{{ phone }}" class="action-btn"> | |
| <img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z'/%3E%3C/svg%3E" alt="Call"> | |
| </a> | |
| <a href="https://wa.me/{{ clean_phone(phone) }}" class="action-btn" target="_blank"> | |
| <img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M12.04 2c-5.46 0-9.91 4.45-9.91 9.91 0 1.75.46 3.45 1.32 4.95L2 22l5.25-1.38c1.45.79 3.08 1.21 4.79 1.21h.01c5.46 0 9.91-4.45 9.91-9.91s-4.45-9.91-9.91-9.91zM17.5 14.3c-.28-.14-1.7-.84-1.96-.94-.26-.1-.45-.14-.64.14-.19.28-.74.94-.91 1.12-.17.18-.34.2-.62.06-.28-.14-1.17-.43-2.23-1.38-.83-.72-1.39-1.62-1.55-1.9-.16-.28 0-.43.13-.57.11-.11.28-.28.42-.42.12-.12.16-.2.24-.34.08-.14.04-.26 0-.4L10.1 8.9c-.1-.28-.2-.28-.28-.28h-.28c-.28 0-.48.06-.73.34-.25.28-.96.94-.96 2.28 0 1.34.99 2.64 1.12 2.82.14.18 1.96 2.99 4.75 4.2.65.28 1.16.45 1.56.58.56.18 1.06.16 1.46.1.44-.08 1.3-.53 1.48-1.04.18-.5.18-.94.12-1.04-.06-.1-.24-.16-.52-.3z'/%3E%3C/svg%3E" alt="WhatsApp"> | |
| </a> | |
| <a href="https://t.me/{{ clean_phone(phone) }}" class="action-btn" target="_blank"> | |
| <img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M22 2L11 13l-4 1 1-4 11-11-17 9v11l5-4.5 3 2.9L22 2z'/%3E%3C/svg%3E" alt="Telegram"> | |
| </a> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| <div class="info-block"> | |
| <div class="info-label">Адрес</div> | |
| <div class="info-value">{{ org_info.address }}</div> | |
| </div> | |
| {% if org_info.links %} | |
| <div class="info-block"> | |
| <div class="info-label">Ссылки</div> | |
| {% for link in org_info.links %} | |
| <a href="{{ link.url }}" target="_blank" style="display: block; color: var(--brand-yellow); margin-bottom: 8px; text-decoration: none;">{{ link.label }}</a> | |
| {% endfor %} | |
| </div> | |
| {% endif %} | |
| </div> | |
| </div> | |
| <script> | |
| const tg = window.Telegram.WebApp; | |
| tg.ready(); | |
| tg.expand(); | |
| function applyTheme(themeParams) { | |
| const root = document.documentElement; | |
| root.style.setProperty('--brand-black', themeParams.bg_color || '#101010'); | |
| root.style.setProperty('--text-color', themeParams.text_color || '#ffffff'); | |
| root.style.setProperty('--text-secondary-color', themeParams.hint_color || '#a0a0a0'); | |
| root.style.setProperty('--brand-yellow', themeParams.button_color || '#FFC107'); | |
| root.style.setProperty('--card-bg', themeParams.secondary_bg_color || '#1c1c1e'); | |
| } | |
| if (tg.themeParams && Object.keys(tg.themeParams).length > 0) { applyTheme(tg.themeParams); } | |
| tg.onEvent('themeChanged', () => applyTheme(tg.themeParams)); | |
| tg.BackButton.show(); | |
| tg.BackButton.onClick(() => window.location.href = `{{ url_for('index', user_id_for_test=user.id) }}`); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # =========== ADMIN-SIDE TEMPLATE =========== | |
| ADMIN_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Bonus Admin</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { --admin-bg: #f8f9fa; --admin-text: #212529; --admin-card-bg: #ffffff; --admin-border: #dee2e6; --admin-shadow: rgba(0, 0, 0, 0.05); --admin-primary: #FFC107; --admin-primary-dark: #e0a800; --admin-secondary: #6c757d; --admin-success: #198754; --admin-danger: #dc3545; --border-radius: 12px; --padding: 1.5rem; --font-family: 'Inter', sans-serif; } | |
| body { font-family: var(--font-family); background-color: var(--admin-bg); color: var(--admin-text); margin: 0; padding: var(--padding); line-height: 1.6; } | |
| .container { max-width: 1400px; margin: 0 auto; } | |
| h1, h2 { text-align: center; color: var(--admin-secondary); margin-bottom: var(--padding); font-weight: 600; } | |
| .grid-container { display: grid; grid-template-columns: 3fr 1fr; gap: var(--padding); } | |
| .main-content { display: flex; flex-direction: column; gap: var(--padding); } | |
| .side-content { display: flex; flex-direction: column; gap: var(--padding); } | |
| .card { background: var(--admin-card-bg); padding: var(--padding); border-radius: var(--border-radius); box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); } | |
| .summary-bar { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--padding); } | |
| .summary-card .value { font-size: 2em; font-weight: 700; } | |
| .summary-card .label { font-size: 0.9em; color: var(--admin-secondary); margin-top: 0.5rem; } | |
| .summary-card .value.bonus { color: var(--admin-primary-dark); } | |
| .summary-card .value.debt { color: var(--admin-danger); } | |
| .controls-bar { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; } | |
| .controls-bar input[type="text"] { flex-grow: 1; padding: 12px 15px; font-size: 1.1em; border-radius: 8px; border: 1px solid var(--admin-border); min-width: 250px; } | |
| .btn { padding: 12px 20px; font-size: 1em; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: background-color 0.2s ease; } | |
| .btn-primary { background-color: var(--admin-primary); color: #000; } | |
| .btn-primary:hover { background-color: var(--admin-primary-dark); } | |
| .btn-delete { background-color: var(--admin-danger); color: white; } | |
| .btn-delete:hover { background-color: #c82333; } | |
| .btn-success { background-color: var(--admin-success); color: white; } | |
| .btn-success:hover { background-color: #157347; } | |
| .user-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--padding); } | |
| .user-card { background-color: var(--admin-card-bg); border-radius: var(--border-radius); padding: var(--padding); box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); display: flex; flex-direction: column; transition: transform 0.2s ease, box-shadow 0.2s ease; } | |
| .user-card:hover { transform: translateY(-5px); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); } | |
| .user-info { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; } | |
| .user-info img { width: 60px; height: 60px; border-radius: 50%; object-fit: cover; border: 3px solid var(--admin-border); background-color: #eee; } | |
| .user-details .name { font-weight: 600; font-size: 1.2em; } | |
| .user-details .username { color: var(--admin-secondary); font-size: 0.9em; } | |
| .user-balances { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; text-align: center; margin-bottom: 1rem; } | |
| .user-balances .label { font-size: 0.9em; color: var(--admin-secondary); } | |
| .user-balances .amount { font-size: 1.8em; font-weight: 700; } | |
| .user-balances .amount.bonus { color: var(--admin-primary-dark); } | |
| .user-balances .amount.debt { color: var(--admin-danger); } | |
| .user-actions { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; } | |
| .btn-manage { display: block; width: 100%; padding: 10px; background-color: var(--admin-primary); color: #000; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: background-color 0.2s; } | |
| .btn-manage:hover { background-color: var(--admin-primary-dark); } | |
| .no-users { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; padding: 2rem; } | |
| .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.5); backdrop-filter: blur(5px); } | |
| .modal-content { background-color: var(--admin-bg); margin: 3% auto; padding: var(--padding); border: 1px solid var(--admin-border); width: 90%; max-width: 900px; border-radius: var(--border-radius); position: relative; box-shadow: 0 8px 30px rgba(0,0,0,0.15); } | |
| .modal-close { color: #aaa; position: absolute; top: 15px; right: 25px; font-size: 28px; font-weight: bold; cursor: pointer; } | |
| .modal-header { padding-bottom: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--admin-border); } | |
| .modal-header h2 { margin: 0; font-size: 1.5rem; } | |
| .modal-header .username { font-size: 1rem; color: var(--admin-secondary); } | |
| .modal-tabs { display: flex; border-bottom: 1px solid var(--admin-border); margin-bottom: 1.5rem; } | |
| .modal-tab { padding: 10px 20px; cursor: pointer; border: none; background: none; font-size: 1.1em; font-weight: 500; color: var(--admin-secondary); } | |
| .modal-tab.active { color: var(--admin-text); border-bottom: 3px solid var(--admin-primary); } | |
| .modal-tab-content { display: none; } | |
| .modal-tab-content.active { display: block; } | |
| .form-section { border: 1px solid var(--admin-border); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; } | |
| .form-section h3 { margin-top: 0; margin-bottom: 1rem; font-size: 1.1em; } | |
| .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: flex-end; } | |
| .form-group { display: flex; flex-direction: column; } | |
| .form-group label { margin-bottom: 0.5rem; font-weight: 500; font-size: 0.9em; } | |
| .form-group input, .form-group textarea { padding: 10px; font-size: 1rem; border: 1px solid var(--admin-border); border-radius: 8px; width: 100%; box-sizing: border-box; } | |
| .calculation-summary { background: #f0f0f0; padding: 1rem; border-radius: 8px; margin-top: 1rem; } | |
| .summary-item { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.95em; } | |
| .summary-item strong { font-weight: 600; } | |
| .history-container { margin-top: 1.5rem; } | |
| .history-container h3 { font-size: 1.2rem; margin-bottom: 1rem; } | |
| .history-list { list-style: none; padding: 0; max-height: 200px; overflow-y: auto; border: 1px solid var(--admin-border); border-radius: 8px; } | |
| .history-item { display: flex; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--admin-border); } | |
| .history-item:last-child { border-bottom: none; } | |
| .history-item .desc { font-size: 0.9em; } | |
| .history-item .date { font-size: 0.8em; color: var(--admin-secondary); } | |
| .history-item .amount.bonus-accrual { color: var(--admin-success); font-weight: 600; } | |
| .history-item .amount.bonus-deduction { color: var(--admin-danger); font-weight: 600; } | |
| .history-item .amount.debt-accrual { color: var(--admin-danger); font-weight: 600; } | |
| .history-item .amount.debt-payment { color: var(--admin-success); font-weight: 600; } | |
| .modal-footer { margin-top: 1.5rem; display: flex; justify-content: flex-end; align-items: center; gap: 1rem;} | |
| #invoice-items-table { width: 100%; margin-top: 1rem; border-collapse: collapse; } | |
| #invoice-items-table th, #invoice-items-table td { padding: 8px; border: 1px solid var(--admin-border); text-align: left;} | |
| #invoice-items-table .btn-delete { padding: 4px 8px; font-size: 0.8em; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Панель администратора Bonus</h1> | |
| <div class="grid-container"> | |
| <div class="main-content"> | |
| <div class="card"> | |
| <div class="summary-bar"> | |
| <div class="summary-card"> | |
| <div class="value">{{ summary.total_users }}</div><div class="label">Всего клиентов</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="value bonus">{{ "%.2f"|format(summary.total_bonuses|float) }}</div><div class="label">Всего бонусов</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="value debt">{{ "%.2f"|format(summary.total_debts|float) }}</div><div class="label">Всего долгов</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="value debt">{{ summary.users_with_debt }}</div><div class="label">Клиенты с долгом</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="controls-bar"> | |
| <input type="text" id="searchInput" onkeyup="searchUsers()" placeholder="Поиск по имени, ID, username, номеру..."> | |
| <button class="btn btn-primary" onclick="openAddClientModal()">Добавить клиента</button> | |
| </div> | |
| {% if users %} | |
| <div class="user-grid" id="userGrid"> | |
| {% for user in users|sort(attribute='visited_at', reverse=true) %} | |
| <div class="user-card" data-user-id="{{ user.id }}" data-search-term="{{ user.first_name|lower }} {{ user.last_name|lower }} {{ user.username|lower }} {{ user.id }} {{ user.phone_number|lower if user.phone_number }}"> | |
| <div class="user-info"> | |
| <img src="{{ user.photo_url if user.photo_url else 'data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 100 100%27%3e%3crect width=%27100%27 height=%27100%27 fill=%27%23e9ecef%27/%3e%3ctext x=%2750%25%27 y=%2755%25%27 dominant-baseline=%27middle%27 text-anchor=%27middle%27 font-size=%2745%27 font-family=%27sans-serif%27 fill=%27%23adb5bd%27%3e?%3c/text%3e%3c/svg%3e' }}" alt="User Avatar"> | |
| <div class="user-details"> | |
| <div class="name">{{ user.first_name or '' }} {{ user.last_name or '' }}</div> | |
| <div class="username">@{{ user.username if user.username else user.phone_number }} | ID: {{ user.id }}</div> | |
| </div> | |
| </div> | |
| <div class="user-balances"> | |
| <div><div class="label">Бонусы</div><div class="amount bonus">{{ "%.2f"|format(user.bonuses|float) }}</div></div> | |
| <div><div class="label">Долг</div><div class="amount debt">{{ "%.2f"|format(user.debts|float if user.debts else 0) }}</div></div> | |
| </div> | |
| <div class="user-actions"> | |
| <button class="btn-manage" onclick='openTransactionModal({{ user|tojson }})'>Управление счетом</button> | |
| {% if user.telegram_id == None %}<button class="btn btn-delete" onclick='deleteClient("{{ user.id }}")'>Удалить клиента</button>{% endif %} | |
| </div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| {% else %} | |
| <p class="no-users">Пользователей пока нет.</p> | |
| {% endif %} | |
| </div> | |
| </div> | |
| <div class="side-content"> | |
| <div class="card"> | |
| <h2>Визитка</h2> | |
| <form action="/admin/update_organization_info" method="post"> | |
| <div class="form-group" style="margin-bottom: 1rem;"> | |
| <label for="orgName">Название организации</label> | |
| <input type="text" id="orgName" name="name" value="{{ org_info.name }}"> | |
| </div> | |
| <div class="form-group" style="margin-bottom: 1rem;"> | |
| <label for="orgPhones">Телефоны (каждый с новой строки)</label> | |
| <textarea id="orgPhones" name="phones" rows="3">{{ org_info.phones|join('\n') }}</textarea> | |
| </div> | |
| <div class="form-group" style="margin-bottom: 1rem;"> | |
| <label for="orgAddress">Адрес</label> | |
| <input type="text" id="orgAddress" name="address" value="{{ org_info.address }}"> | |
| </div> | |
| <div class="form-group" style="margin-bottom: 1.5rem;"> | |
| <label for="orgLinks">Ссылки (формат: Название, URL; каждая с новой строки)</label> | |
| <textarea id="orgLinks" name="links" rows="3">{% for link in org_info.links %}{{ link.label }}, {{ link.url }}{{ '\n' if not loop.last }}{% endfor %}</textarea> | |
| </div> | |
| <button type="submit" class="btn btn-success" style="width: 100%;">Сохранить визитку</button> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="transactionModal" class="modal"> | |
| <div class="modal-content"> | |
| <span class="modal-close" onclick="closeModal('transactionModal')">×</span> | |
| <div class="modal-header"> | |
| <h2 id="modalUserName"></h2><div id="modalUserUsername" class="username"></div> | |
| </div> | |
| <input type="hidden" id="modalUserId"> | |
| <div class="modal-tabs"> | |
| <button class="modal-tab active" onclick="switchTab(event, 'operations')">Операции</button> | |
| <button class="modal-tab" onclick="switchTab(event, 'invoices')">Накладные</button> | |
| </div> | |
| <div id="operations" class="modal-tab-content active"> | |
| <div class="form-section"> | |
| <h3>Бонусы</h3> | |
| <div class="form-row"> | |
| <div class="form-group"><label for="purchaseAmount">Сумма покупки (для начисления)</label><input type="number" id="purchaseAmount" placeholder="1500" oninput="updateCalculations()"></div> | |
| <div class="form-group"><label for="deductAmount">Списать бонусов</label><input type="number" id="deductAmount" placeholder="100" oninput="updateCalculations()"></div> | |
| </div> | |
| <div class="calculation-summary"> | |
| <div class="summary-item"><span>Текущий баланс:</span> <strong id="summaryCurrentBalance">0.00</strong></div> | |
| <div class="summary-item"><span>Будет начислено (2%):</span> <strong id="summaryAccrual">+0.00</strong></div> | |
| <div class="summary-item"><span>Будет списано:</span> <strong id="summaryDeduction">-0.00</strong></div><hr> | |
| <div class="summary-item"><strong>Итоговый баланс бонусов:</strong> <strong id="summaryFinalBalance">0.00</strong></div> | |
| </div> | |
| </div> | |
| <div class="form-section"> | |
| <h3>Долги</h3> | |
| <div class="form-row"> | |
| <div class="form-group"><label for="addDebtAmount">Добавить долг</label><input type="number" id="addDebtAmount" placeholder="500" oninput="updateCalculations()"></div> | |
| <div class="form-group"><label for="repayDebtAmount">Погасить долг</label><input type="number" id="repayDebtAmount" placeholder="200" oninput="updateCalculations()"></div> | |
| </div> | |
| <div class="calculation-summary"> | |
| <div class="summary-item"><span>Текущий долг:</span> <strong id="summaryCurrentDebt">0.00</strong></div> | |
| <div class="summary-item"><span>Будет добавлено:</span> <strong id="summaryAddDebt">+0.00</strong></div> | |
| <div class="summary-item"><span>Будет погашено:</span> <strong id="summaryRepayDebt">-0.00</strong></div><hr> | |
| <div class="summary-item"><strong>Итоговый долг:</strong> <strong id="summaryFinalDebt">0.00</strong></div> | |
| </div> | |
| </div> | |
| <div class="modal-footer"> | |
| <div id="modalStatus" class="status-message"></div> | |
| <button class="btn btn-success" onclick="submitTransaction()">Провести операцию</button> | |
| </div> | |
| </div> | |
| <div id="invoices" class="modal-tab-content"> | |
| <div class="form-section"> | |
| <h3>Создать накладную</h3> | |
| <div style="display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 1rem; align-items: flex-end;"> | |
| <div class="form-group"><label for="itemName">Название товара</label><input type="text" id="itemName"></div> | |
| <div class="form-group"><label for="itemQty">Кол-во</label><input type="number" id="itemQty" value="1"></div> | |
| <div class="form-group"><label for="itemPrice">Цена</label><input type="number" id="itemPrice"></div> | |
| <button class="btn btn-primary" onclick="addInvoiceItem()">+</button> | |
| </div> | |
| <table id="invoice-items-table"> | |
| <thead><tr><th>Товар</th><th>Кол-во</th><th>Цена</th><th>Сумма</th><th></th></tr></thead> | |
| <tbody id="invoiceItemsTbody"></tbody> | |
| </table> | |
| <div class="calculation-summary"> | |
| <div class="summary-item"><strong>Итого по накладной:</strong> <strong id="invoiceTotal">0.00</strong></div> | |
| </div> | |
| </div> | |
| <div class="history-container"><h3>Существующие накладные</h3><ul id="modalInvoicesList" class="history-list"></ul></div> | |
| <div class="modal-footer"> | |
| <div id="invoiceStatus" class="status-message"></div> | |
| <button class="btn btn-success" onclick="saveInvoice()">Сохранить накладную</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="addClientModal" class="modal"> | |
| <div class="modal-content" style="max-width: 500px;"> | |
| <span class="modal-close" onclick="closeModal('addClientModal')">×</span> | |
| <div class="modal-header"><h2>Добавить нового клиента</h2></div> | |
| <div class="form-group" style="margin-bottom: 1rem;"><label for="newClientFirstName">Имя</label><input type="text" id="newClientFirstName" placeholder="Иван"></div> | |
| <div class="form-group" style="margin-bottom: 1.5rem;"><label for="newClientPhone">Номер телефона (уникальный)</label><input type="tel" id="newClientPhone" placeholder="+996..."></div> | |
| <div class="modal-footer"><div id="addClientStatus" class="status-message"></div><button class="btn btn-success" onclick="submitNewClient()">Сохранить клиента</button></div> | |
| </div> | |
| </div> | |
| <script> | |
| const transactionModal = document.getElementById('transactionModal'); | |
| const addClientModal = document.getElementById('addClientModal'); | |
| let currentUserData = null; | |
| let currentInvoiceItems = []; | |
| function searchUsers() { | |
| const searchTerm = document.getElementById('searchInput').value.toLowerCase(); | |
| document.querySelectorAll('.user-card').forEach(card => { | |
| card.style.display = card.getAttribute('data-search-term').includes(searchTerm) ? 'flex' : 'none'; | |
| }); | |
| } | |
| function openTransactionModal(userData) { | |
| currentUserData = userData; | |
| document.getElementById('modalUserId').value = userData.id; | |
| document.getElementById('modalUserName').textContent = `${userData.first_name || ''} ${userData.last_name || ''}`; | |
| document.getElementById('modalUserUsername').textContent = `@${userData.username || userData.phone_number} | ID: ${userData.id}`; | |
| ['purchaseAmount', 'deductAmount', 'addDebtAmount', 'repayDebtAmount'].forEach(id => document.getElementById(id).value = ''); | |
| ['modalStatus', 'invoiceStatus'].forEach(id => document.getElementById(id).textContent = ''); | |
| const invoicesList = document.getElementById('modalInvoicesList'); | |
| invoicesList.innerHTML = ''; | |
| if (userData.invoices && userData.invoices.length > 0) { | |
| userData.invoices.sort((a,b) => new Date(b.date) - new Date(a.date)).forEach(inv => { | |
| const li = document.createElement('li'); | |
| li.className = 'history-item'; | |
| li.innerHTML = `<div><div class="desc">Накладная #${inv.id}</div><div class="date">${inv.date_str}</div></div><div class="amount"><strong>${parseFloat(inv.total_amount).toFixed(2)}</strong></div>`; | |
| invoicesList.appendChild(li); | |
| }); | |
| } else { | |
| invoicesList.innerHTML = '<li style="text-align:center; padding: 1rem; color: var(--admin-secondary);">Нет накладных</li>'; | |
| } | |
| currentInvoiceItems = []; | |
| renderInvoiceItems(); | |
| updateCalculations(); | |
| switchTab({ currentTarget: document.querySelector('.modal-tab.active') }, 'operations'); | |
| transactionModal.style.display = 'block'; | |
| } | |
| function openAddClientModal() { | |
| ['newClientFirstName', 'newClientPhone'].forEach(id => document.getElementById(id).value = ''); | |
| document.getElementById('addClientStatus').textContent = ''; | |
| addClientModal.style.display = 'block'; | |
| } | |
| function closeModal(modalId) { | |
| document.getElementById(modalId).style.display = 'none'; | |
| if (modalId === 'transactionModal') currentUserData = null; | |
| } | |
| function switchTab(evt, tabName) { | |
| document.querySelectorAll('.modal-tab-content').forEach(tc => tc.classList.remove('active')); | |
| document.querySelectorAll('.modal-tab').forEach(t => t.classList.remove('active')); | |
| document.getElementById(tabName).classList.add('active'); | |
| evt.currentTarget.classList.add('active'); | |
| } | |
| function updateCalculations() { | |
| if (!currentUserData) return; | |
| const currentBalance = parseFloat(currentUserData.bonuses) || 0; | |
| const purchaseAmount = parseFloat(document.getElementById('purchaseAmount').value) || 0; | |
| let deductAmount = parseFloat(document.getElementById('deductAmount').value) || 0; | |
| const accrualAmount = purchaseAmount * 0.02; | |
| if (deductAmount > currentBalance + accrualAmount) { | |
| deductAmount = currentBalance + accrualAmount; | |
| document.getElementById('deductAmount').value = deductAmount > 0 ? deductAmount.toFixed(2) : ''; | |
| } | |
| const finalBalance = currentBalance + accrualAmount - deductAmount; | |
| document.getElementById('summaryCurrentBalance').textContent = currentBalance.toFixed(2); | |
| document.getElementById('summaryAccrual').textContent = `+${accrualAmount.toFixed(2)}`; | |
| document.getElementById('summaryDeduction').textContent = `-${deductAmount.toFixed(2)}`; | |
| document.getElementById('summaryFinalBalance').textContent = finalBalance.toFixed(2); | |
| const currentDebt = parseFloat(currentUserData.debts) || 0; | |
| const addDebtAmount = parseFloat(document.getElementById('addDebtAmount').value) || 0; | |
| let repayDebtAmount = parseFloat(document.getElementById('repayDebtAmount').value) || 0; | |
| if (repayDebtAmount > currentDebt) { | |
| repayDebtAmount = currentDebt; | |
| document.getElementById('repayDebtAmount').value = repayDebtAmount > 0 ? repayDebtAmount.toFixed(2) : ''; | |
| } | |
| const finalDebt = currentDebt + addDebtAmount - repayDebtAmount; | |
| document.getElementById('summaryCurrentDebt').textContent = currentDebt.toFixed(2); | |
| document.getElementById('summaryAddDebt').textContent = `+${addDebtAmount.toFixed(2)}`; | |
| document.getElementById('summaryRepayDebt').textContent = `-${repayDebtAmount.toFixed(2)}`; | |
| document.getElementById('summaryFinalDebt').textContent = finalDebt.toFixed(2); | |
| } | |
| function addInvoiceItem() { | |
| const name = document.getElementById('itemName').value.trim(); | |
| const qty = parseFloat(document.getElementById('itemQty').value) || 1; | |
| const price = parseFloat(document.getElementById('itemPrice').value) || 0; | |
| if (!name || qty <= 0 || price <= 0) { alert('Введите корректные данные для товара.'); return; } | |
| currentInvoiceItems.push({ name: name, quantity: qty, price_per_unit: price, total_price: qty * price }); | |
| document.getElementById('itemName').value = ''; | |
| document.getElementById('itemQty').value = '1'; | |
| document.getElementById('itemPrice').value = ''; | |
| renderInvoiceItems(); | |
| document.getElementById('itemName').focus(); | |
| } | |
| function removeInvoiceItem(index) { | |
| currentInvoiceItems.splice(index, 1); | |
| renderInvoiceItems(); | |
| } | |
| function renderInvoiceItems() { | |
| const tbody = document.getElementById('invoiceItemsTbody'); | |
| tbody.innerHTML = ''; | |
| let total = 0; | |
| currentInvoiceItems.forEach((item, index) => { | |
| const row = tbody.insertRow(); | |
| row.innerHTML = `<td>${item.name}</td><td>${item.quantity}</td><td>${item.price_per_unit.toFixed(2)}</td><td>${item.total_price.toFixed(2)}</td><td><button class="btn btn-delete" onclick="removeInvoiceItem(${index})">X</button></td>`; | |
| total += item.total_price; | |
| }); | |
| document.getElementById('invoiceTotal').textContent = total.toFixed(2); | |
| } | |
| async function doFetch(url, payload, statusElId) { | |
| const statusEl = document.getElementById(statusElId); | |
| statusEl.style.color = 'var(--admin-secondary)'; | |
| statusEl.textContent = 'Обработка...'; | |
| try { | |
| const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); | |
| const result = await response.json(); | |
| if (!response.ok) throw new Error(result.message || 'Произошла ошибка'); | |
| statusEl.style.color = 'var(--admin-success)'; | |
| statusEl.textContent = result.message || 'Успешно!'; | |
| setTimeout(() => location.reload(), 1500); | |
| } catch (error) { | |
| statusEl.style.color = 'var(--admin-danger)'; | |
| statusEl.textContent = `Ошибка: ${error.message}`; | |
| } | |
| } | |
| function submitTransaction() { | |
| const payload = { | |
| user_id: document.getElementById('modalUserId').value, | |
| purchase_amount: parseFloat(document.getElementById('purchaseAmount').value) || 0, | |
| deduct_amount: parseFloat(document.getElementById('deductAmount').value) || 0, | |
| add_debt_amount: parseFloat(document.getElementById('addDebtAmount').value) || 0, | |
| repay_debt_amount: parseFloat(document.getElementById('repayDebtAmount').value) || 0, | |
| }; | |
| if (Object.values(payload).slice(1).every(v => v <= 0)) { | |
| document.getElementById('modalStatus').textContent = 'Введите сумму для операции.'; | |
| return; | |
| } | |
| doFetch('/admin/add_transaction', payload, 'modalStatus'); | |
| } | |
| function saveInvoice() { | |
| if (currentInvoiceItems.length === 0) { | |
| document.getElementById('invoiceStatus').textContent = 'Добавьте хотя бы один товар.'; | |
| return; | |
| } | |
| const payload = { user_id: document.getElementById('modalUserId').value, items: currentInvoiceItems }; | |
| doFetch('/admin/add_invoice', payload, 'invoiceStatus'); | |
| } | |
| function submitNewClient() { | |
| const payload = { first_name: document.getElementById('newClientFirstName').value.trim(), phone_number: document.getElementById('newClientPhone').value.trim() }; | |
| if (!payload.first_name || !payload.phone_number) { | |
| document.getElementById('addClientStatus').textContent = 'Имя и номер телефона обязательны.'; | |
| return; | |
| } | |
| doFetch('/admin/add_client', payload, 'addClientStatus'); | |
| } | |
| async function deleteClient(userId) { | |
| if (!confirm(`Вы уверены, что хотите удалить клиента с ID ${userId}? Это действие необратимо.`)) return; | |
| await doFetch('/admin/delete_client', { user_id: userId }, 'modalStatus'); | |
| } | |
| window.onclick = function(event) { | |
| if (event.target == transactionModal) closeModal('transactionModal'); | |
| if (event.target == addClientModal) closeModal('addClientModal'); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| def index(): | |
| page = request.args.get('page', 'home') | |
| user_id_str = request.args.get('user_id_for_test') | |
| current_data = load_visitor_data() | |
| user_data = {} | |
| if user_id_str and user_id_str in current_data: | |
| user_data = current_data[user_id_str] | |
| user_data['id'] = user_id_str | |
| else: | |
| user_data = { | |
| "id": "N/A", "bonuses": 0, "debts": 0, "first_name": "Гость", | |
| "history": [], "debt_history": [], "invoices": [] | |
| } | |
| if page == 'card': | |
| org_info = load_org_info() | |
| return render_template_string(CARD_TEMPLATE, user=user_data, org_info=org_info, clean_phone=clean_phone_number) | |
| elif page == 'history': | |
| bonus_history = [dict(item, transaction_type='bonus') for item in user_data.get('history', [])] | |
| debt_history = [dict(item, transaction_type='debt') for item in user_data.get('debt_history', [])] | |
| user_data['combined_history'] = sorted(bonus_history + debt_history, key=lambda x: x['date'], reverse=True) | |
| return render_template_string(HISTORY_TEMPLATE, user=user_data) | |
| elif page == 'invoices': | |
| if 'invoices' in user_data and user_data['invoices']: | |
| user_data['invoices'] = sorted(user_data['invoices'], key=lambda x: x['date'], reverse=True) | |
| return render_template_string(INVOICES_TEMPLATE, user=user_data) | |
| else: # home | |
| return render_template_string(MAIN_TEMPLATE, user=user_data) | |
| def verify_data(): | |
| try: | |
| req_data = request.get_json() | |
| init_data_str = req_data.get('initData') | |
| if not init_data_str: | |
| return jsonify({"status": "error", "message": "Missing initData"}), 400 | |
| user_data_parsed, is_valid = verify_telegram_data(init_data_str) | |
| user_info_dict = json.loads(unquote(user_data_parsed['user'][0])) if user_data_parsed and 'user' in user_data_parsed else {} | |
| if is_valid and user_info_dict.get('id'): | |
| tg_user_id = str(user_info_dict['id']) | |
| now = datetime.now(BISHKEK_TZ) | |
| all_data = load_visitor_data() | |
| existing_user_key = next((k for k, v in all_data.items() if str(v.get('telegram_id')) == tg_user_id), None) | |
| if existing_user_key: | |
| user_entry = all_data[existing_user_key] | |
| user_entry.update({ | |
| 'first_name': user_info_dict.get('first_name'), 'last_name': user_info_dict.get('last_name'), | |
| 'username': user_info_dict.get('username'), 'photo_url': user_info_dict.get('photo_url'), | |
| 'visited_at': now.timestamp(), 'visited_at_str': now.strftime('%Y-%m-%d %H:%M:%S') | |
| }) | |
| user_id_to_save = existing_user_key | |
| else: | |
| new_user_id = generate_unique_id(all_data) | |
| user_entry = { | |
| 'id': new_user_id, 'telegram_id': tg_user_id, 'bonuses': 0, 'history': [], 'debts': 0, 'debt_history': [], 'invoices': [], | |
| 'first_name': user_info_dict.get('first_name'), 'last_name': user_info_dict.get('last_name'), | |
| 'username': user_info_dict.get('username'), 'photo_url': user_info_dict.get('photo_url'), | |
| 'phone_number': None, 'visited_at': now.timestamp(), 'visited_at_str': now.strftime('%Y-%m-%d %H:%M:%S') | |
| } | |
| user_id_to_save = new_user_id | |
| save_visitor_data({user_id_to_save: user_entry}) | |
| return jsonify({"status": "ok", "verified": True, "user_id": user_id_to_save}) | |
| else: | |
| return jsonify({"status": "error", "verified": is_valid, "message": "Invalid data or missing user ID"}), 403 | |
| except Exception as e: | |
| return jsonify({"status": "error", "message": "Internal server error"}), 500 | |
| def admin_panel(): | |
| current_data = load_visitor_data() | |
| org_info = load_org_info() | |
| users_list = [dict(v, id=k) for k, v in current_data.items()] | |
| summary_stats = { | |
| "total_users": len(users_list), | |
| "total_bonuses": sum(u.get('bonuses', 0) for u in users_list), | |
| "total_debts": sum(u.get('debts', 0) for u in users_list), | |
| "users_with_debt": sum(1 for u in users_list if u.get('debts', 0) > 0) | |
| } | |
| return render_template_string(ADMIN_TEMPLATE, users=users_list, summary=summary_stats, org_info=org_info) | |
| def update_organization_info(): | |
| try: | |
| data = request.form | |
| phones = [p.strip() for p in data.get('phones', '').splitlines() if p.strip()] | |
| links_raw = [p.strip() for p in data.get('links', '').splitlines() if p.strip()] | |
| links = [] | |
| for line in links_raw: | |
| parts = [p.strip() for p in line.split(',', 1)] | |
| if len(parts) == 2: | |
| links.append({"label": parts[0], "url": parts[1]}) | |
| org_info = { | |
| "name": data.get('name', ''), | |
| "phones": phones, | |
| "address": data.get('address', ''), | |
| "links": links | |
| } | |
| save_org_info(org_info) | |
| return redirect(url_for('admin_panel')) | |
| except Exception: | |
| return "Error", 500 | |
| def add_client(): | |
| try: | |
| data = request.get_json() | |
| phone_number = data.get('phone_number') | |
| first_name = data.get('first_name') | |
| if not phone_number or not first_name: | |
| return jsonify({"status": "error", "message": "Имя и номер телефона обязательны."}), 400 | |
| all_data = load_visitor_data() | |
| if any(u.get('phone_number') == phone_number for u in all_data.values()): | |
| return jsonify({"status": "error", "message": "Клиент с таким номером уже существует."}), 409 | |
| now = datetime.now(BISHKEK_TZ) | |
| new_id = generate_unique_id(all_data) | |
| new_client = { | |
| 'id': new_id, 'telegram_id': None, 'first_name': first_name, 'phone_number': phone_number, | |
| 'bonuses': 0, 'history': [], 'debts': 0, 'debt_history': [], 'invoices': [], | |
| 'visited_at': now.timestamp(), 'visited_at_str': now.strftime('%Y-%m-%d %H:%M:%S'), | |
| 'last_name': None, 'username': None, 'photo_url': None | |
| } | |
| save_visitor_data({new_id: new_client}) | |
| return jsonify({"status": "ok", "message": "Клиент успешно добавлен!"}), 201 | |
| except Exception as e: | |
| return jsonify({"status": "error", "message": str(e)}), 500 | |
| def add_transaction(): | |
| try: | |
| data = request.get_json() | |
| user_id, purchase, deduct = str(data.get('user_id')), float(data.get('purchase_amount', 0)), float(data.get('deduct_amount', 0)) | |
| add_debt, repay_debt = float(data.get('add_debt_amount', 0)), float(data.get('repay_debt_amount', 0)) | |
| all_data = load_visitor_data() | |
| if user_id not in all_data: return jsonify({"status": "error", "message": "User not found"}), 404 | |
| user = all_data[user_id] | |
| now = datetime.now(BISHKEK_TZ) | |
| accrual_amount = purchase * 0.02 | |
| new_balance = user.get('bonuses', 0) + accrual_amount | |
| if deduct > new_balance: return jsonify({"status": "error", "message": "Недостаточно бонусов для списания"}), 400 | |
| user['bonuses'] = new_balance - deduct | |
| if accrual_amount > 0: user.setdefault('history', []).append({"type": "accrual", "amount": accrual_amount, "description": f"Начисление с покупки {purchase}", "date": now.isoformat(), "date_str": now.strftime('%Y-%m-%d %H:%M:%S')}) | |
| if deduct > 0: user.setdefault('history', []).append({"type": "deduction", "amount": deduct, "description": "Списание бонусов", "date": now.isoformat(), "date_str": now.strftime('%Y-%m-%d %H:%M:%S')}) | |
| if repay_debt > user.get('debts', 0): return jsonify({"status": "error", "message": "Сумма погашения превышает долг"}), 400 | |
| user['debts'] = user.get('debts', 0) + add_debt - repay_debt | |
| if add_debt > 0: user.setdefault('debt_history', []).append({"type": "accrual", "amount": add_debt, "description": "Добавление долга", "date": now.isoformat(), "date_str": now.strftime('%Y-%m-%d %H:%M:%S')}) | |
| if repay_debt > 0: user.setdefault('debt_history', []).append({"type": "payment", "amount": repay_debt, "description": "Погашение долга", "date": now.isoformat(), "date_str": now.strftime('%Y-%m-%d %H:%M:%S')}) | |
| save_visitor_data({user_id: user}) | |
| return jsonify({"status": "ok", "message": "Операция успешна"}), 200 | |
| except Exception as e: | |
| return jsonify({"status": "error", "message": str(e)}), 500 | |
| def add_invoice(): | |
| try: | |
| data = request.get_json() | |
| user_id = str(data.get('user_id')) | |
| items = data.get('items', []) | |
| if not user_id or not items: | |
| return jsonify({"status": "error", "message": "Требуется ID пользователя и товары"}), 400 | |
| all_data = load_visitor_data() | |
| if user_id not in all_data: | |
| return jsonify({"status": "error", "message": "User not found"}), 404 | |
| user = all_data[user_id] | |
| now = datetime.now(BISHKEK_TZ) | |
| invoice_id = f"{int(now.timestamp()) % 100000}{random.randint(10,99)}" | |
| total_amount = sum(float(item.get('quantity', 0)) * float(item.get('price_per_unit', 0)) for item in items) | |
| new_invoice = { | |
| "id": invoice_id, | |
| "date": now.isoformat(), | |
| "date_str": now.strftime('%Y-%m-%d %H:%M:%S'), | |
| "items": items, | |
| "total_amount": total_amount | |
| } | |
| user.setdefault('invoices', []).append(new_invoice) | |
| save_visitor_data({user_id: user}) | |
| return jsonify({"status": "ok", "message": "Накладная успешно сохранена!"}), 201 | |
| except Exception as e: | |
| return jsonify({"status": "error", "message": str(e)}), 500 | |
| def delete_client(): | |
| try: | |
| user_id = str(request.get_json().get('user_id')) | |
| if not user_id: return jsonify({"status": "error", "message": "User ID is required"}), 400 | |
| load_visitor_data() | |
| with _data_lock: | |
| if user_id not in visitor_data_cache: return jsonify({"status": "error", "message": "User not found"}), 404 | |
| if visitor_data_cache[user_id].get('telegram_id') is not None: return jsonify({"status": "error", "message": "Cannot delete a Telegram-linked user"}), 403 | |
| del visitor_data_cache[user_id] | |
| with open(DATA_FILE, 'w', encoding='utf-8') as f: | |
| json.dump(visitor_data_cache, f, ensure_ascii=False, indent=4) | |
| upload_data_to_hf_async() | |
| return jsonify({"status": "ok", "message": "Клиент удален"}), 200 | |
| except Exception as e: | |
| return jsonify({"status": "error", "message": str(e)}), 500 | |
| if __name__ == '__main__': | |
| if HF_TOKEN_READ: | |
| download_files_from_hf() | |
| load_visitor_data() | |
| load_org_info() | |
| if HF_TOKEN_WRITE: | |
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) | |
| backup_thread.start() | |
| app.run(host=HOST, port=PORT, debug=False) |