Examplebonus22 / app.py
Kgshop's picture
Update app.py
9b6ae69 verified
raw
history blame
72.4 kB
#!/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>
"""
@app.route('/')
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)
@app.route('/verify', methods=['POST'])
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
@app.route('/admin')
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)
@app.route('/admin/update_organization_info', methods=['POST'])
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
@app.route('/admin/add_client', methods=['POST'])
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
@app.route('/admin/add_transaction', methods=['POST'])
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
@app.route('/admin/add_invoice', methods=['POST'])
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
@app.route('/admin/delete_client', methods=['POST'])
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)