Initial commit: Bober BBQ Telegram bot + backend + admin + Mini App

Flask API/admin backend, aiogram bot with delivery/pickup FSM flows,
monobank payment integration, and a Vite/React Telegram Mini App for
menu browsing and cart management.
This commit is contained in:
byrsapty 2026-08-08 15:48:06 +03:00
commit 0eabebaca3
78 changed files with 5917 additions and 0 deletions

36
.env.example Normal file
View file

@ -0,0 +1,36 @@
# === Telegram ===
BOT_TOKEN=123456:AAExampleTelegramBotToken
# Telegram user id(s) of the primary owner/manager (comma separated), used as fallback if chats below are not set
OWNER_IDS=123456789
# Chats where orders are sent. Can be a group id (negative number, e.g. -1001234567890)
# or a channel id. The bot must be added as admin to these chats.
DELIVERY_CHAT_ID=-1001111111111
PICKUP_CHAT_ID=-1002222222222
# Public HTTPS URL where the webapp (Mini App) is hosted, e.g. https://bober-bbq.example.com/webapp/
WEBAPP_URL=https://example.com/webapp/
# Public base URL of this backend (Flask API), e.g. https://bober-bbq.example.com
BACKEND_PUBLIC_URL=https://example.com
# === Database ===
# Leave unset to use SQLite at instance/bober_bbq.db automatically (recommended for dev).
# For production, e.g. postgresql+psycopg2://user:pass@host:5432/bober_bbq
# If you do set a SQLite URL explicitly, use an absolute path (sqlite:///D:/full/path/bober_bbq.db) —
# relative sqlite paths resolve against the process's current working directory, not this project folder.
DATABASE_URL=
# === Flask ===
SECRET_KEY=change-me-to-a-random-secret-string
FLASK_ADMIN_USERNAME=admin
FLASK_ADMIN_PASSWORD=change-me
# === Monobank (provided by the client / FOP owner) ===
# Merchant acquiring token from https://api.monobank.ua/
MONOBANK_TOKEN=
# Webhook URL monobank will call on payment status change
MONOBANK_WEBHOOK_URL=https://example.com/api/payments/monobank/webhook
# === Misc ===
TIMEZONE=Europe/Kyiv

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
.env
__pycache__/
*.pyc
instance/*.db
instance/*.sqlite
bober_bbq/static/uploads/*
!bober_bbq/static/uploads/.gitkeep
webapp/node_modules/
webapp/dist/
*.log
.vscode/

103
README.md Normal file
View file

@ -0,0 +1,103 @@
# Bober BBQ — Telegram Bot + Mini App
Реалізація ТЗ на Telegram-бота та Web App для кафе/доставки Bober BBQ:
меню, кошик (спільний для бота і Mini App), оформлення доставки/самовивозу,
оплата monobank, сповіщення менеджера в Telegram, адмін-панель.
## Структура проєкту
```
bober-bbq-bot/
├── bober_bbq/ # Flask backend: REST API + адмін-панель + БД-моделі
│ ├── api/ # /api/categories, /api/products, /api/cart, /api/settings, monobank webhook
│ ├── admin/ # Адмін-панель (Flask-Login + Jinja2)
│ ├── payments/ # Інтеграція monobank
│ ├── utils/ # Ціноутворення, графік роботи, auth Telegram WebApp, форматування замовлень
│ ├── templates/admin/ # HTML-шаблони адмінки
│ ├── static/ # CSS адмінки + завантажені фото товарів
│ ├── models.py # SQLAlchemy-моделі (users, categories, products, cart, orders, settings)
│ └── app.py # Flask app factory
├── bot/ # Telegram-бот (aiogram 3)
│ ├── handlers/ # /start, меню, кошик, доставка (FSM), самовивіз (FSM), оплата, контакти
│ ├── services/ # Кошик, замовлення, сповіщення менеджеру, оплата
│ ├── keyboards.py # Reply/inline клавіатури
│ └── main.py # Точка входу бота (long polling)
├── webapp/ # Telegram Mini App (Vite + React + TypeScript)
│ └── src/ # Меню, кошик, контакти
├── run_web.py # Продакшн-запуск Flask backend (waitress)
├── run_bot.py # Запуск бота
└── requirements.txt
```
## Як це працює разом
- **Кошик єдиний** для бота і Web App: обидва читають/пишуть одну таблицю
`cart_items` у БД, прив'язану до `telegram_id` користувача (розділ 4 ТЗ).
- **Web App** (кнопка «🍽 Меню») відповідає лише за перегляд меню та кошик.
Кнопки «Оформити доставку» / «Оформити самовивіз» у кошику Mini App
викликають `Telegram.WebApp.sendData(...)` і закривають Web App — далі
покроковий діалог (адреса, ім'я, телефон, оплата) веде вже сам бот,
як і описано в розділах 5 та 7-10 ТЗ.
- **Бот і backend використовують одні й ті самі SQLAlchemy-моделі**
бот працює з БД напряму (через спільний Flask app-контекст), без зайвого
HTTP-стрибка. Web App ходить у backend через REST API з підписом
Telegram `initData` (розділ 18.4 ТЗ — захист API).
- **monobank**: створення інвойсу, кнопка оплати, перевірка статусу і вебхук
підтвердження оплати — розділ `bober_bbq/payments/monobank.py`. Без
`MONOBANK_TOKEN` бот не падає, а пропонує оплату при отриманні і просить
менеджера зв'язатися з клієнтом.
## Швидкий старт (Windows)
1. Встановіть залежності (Python 3.11+, Node 18+):
```bash
pip install -r requirements.txt
cd webapp && npm install && npm run build && cd ..
```
2. Скопіюйте `.env.example``.env` і заповніть значення (токен бота,
чати менеджера, WEBAPP_URL — публічний HTTPS-домен, бо Telegram Mini App
вимагає HTTPS).
3. Запустіть backend (API + адмінка + роздача зібраного Web App):
```bash
python run_web.py
```
Адмінка: `http://localhost:8000/admin` (логін/пароль — `FLASK_ADMIN_USERNAME`
/ `FLASK_ADMIN_PASSWORD` з `.env`, дефолт `admin` / значення з `.env.example`).
4. Запустіть бота (окремий процес):
```bash
python run_bot.py
```
Перше ж підключення до БД автоматично створює таблиці й наповнює їх
демонстративними категоріями/товарами (`bober_bbq/seed.py`) — заміните
на реальне меню через адмінку.
## Локальна розробка Web App окремо (гаряче перезавантаження)
```bash
cd webapp
npm run dev
```
Vite проксує `/api/*` на `http://localhost:8000` (див. `webapp/vite.config.ts`).
Оскільки поза Telegram немає справжнього `initData`, API у режимі
`debug=True` приймає `?dev_user_id=<будь-яке число>` як заглушку для
розробки — **лише коли `FLASK_DEBUG`/`app.debug` увімкнено**, у проді цей
обхід вимкнений.
## Важливо перед продакшн-запуском
- `WEBAPP_URL` і `MONOBANK_WEBHOOK_URL` мають бути реальними HTTPS-адресами
(Telegram Mini App і monobank webhook не працюють по HTTP).
- Додайте бота як адміністратора в чати/групи доставки та самовивозу,
вкажіть їх `chat_id` в Налаштуваннях адмінки.
- Детальніше — [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
- Список пунктів, які треба узгодити із замовником до продакшену
(розділ 22 ТЗ) — [`docs/OPEN_QUESTIONS.md`](docs/OPEN_QUESTIONS.md).

1
bober_bbq/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Bober BBQ — shared backend package (Flask API + admin) used by both the web app and the Telegram bot."""

View file

@ -0,0 +1,5 @@
from flask import Blueprint
admin_bp = Blueprint("admin", __name__, url_prefix="/admin", template_folder="../templates/admin")
from bober_bbq.admin import routes # noqa: E402,F401 (registers routes on admin_bp)

274
bober_bbq/admin/routes.py Normal file
View file

@ -0,0 +1,274 @@
import uuid
from pathlib import Path
from flask import flash, redirect, render_template, request, url_for
from flask_login import login_required, login_user, logout_user
from werkzeug.utils import secure_filename
from bober_bbq.admin import admin_bp
from bober_bbq.config import config
from bober_bbq.extensions import db
from bober_bbq.models import (
DEFAULT_SETTINGS,
ORDER_STATUSES,
AdminUser,
Category,
Order,
Product,
Setting,
)
ALLOWED_IMAGE_EXT = {"jpg", "jpeg", "png", "webp"}
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
@admin_bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "")
password = request.form.get("password", "")
user = AdminUser.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user)
return redirect(url_for("admin.dashboard"))
flash("Невірний логін або пароль", "error")
return render_template("admin/login.html")
@admin_bp.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("admin.login"))
# ---------------------------------------------------------------------------
# Dashboard
# ---------------------------------------------------------------------------
@admin_bp.route("/")
@login_required
def dashboard():
recent_orders = Order.query.order_by(Order.created_at.desc()).limit(15).all()
stats = {
"new_orders": Order.query.filter_by(status="new").count(),
"products": Product.query.count(),
"categories": Category.query.count(),
"total_orders": Order.query.count(),
}
return render_template("admin/dashboard.html", orders=recent_orders, stats=stats)
# ---------------------------------------------------------------------------
# Categories
# ---------------------------------------------------------------------------
@admin_bp.route("/categories")
@login_required
def categories_list():
categories = Category.query.order_by(Category.sort_order).all()
return render_template("admin/categories.html", categories=categories)
@admin_bp.route("/categories/new", methods=["POST"])
@login_required
def categories_new():
name = request.form.get("name", "").strip()
slug = request.form.get("slug", "").strip() or _slugify(name)
if not name:
flash("Назва категорії обовʼязкова", "error")
return redirect(url_for("admin.categories_list"))
max_order = db.session.query(db.func.max(Category.sort_order)).scalar() or 0
db.session.add(Category(name=name, slug=slug, sort_order=max_order + 1))
db.session.commit()
flash("Категорію додано", "success")
return redirect(url_for("admin.categories_list"))
@admin_bp.route("/categories/<int:category_id>/edit", methods=["POST"])
@login_required
def categories_edit(category_id):
category = Category.query.get_or_404(category_id)
category.name = request.form.get("name", category.name).strip()
category.is_active = bool(request.form.get("is_active"))
db.session.commit()
flash("Категорію оновлено", "success")
return redirect(url_for("admin.categories_list"))
@admin_bp.route("/categories/<int:category_id>/delete", methods=["POST"])
@login_required
def categories_delete(category_id):
category = Category.query.get_or_404(category_id)
db.session.delete(category)
db.session.commit()
flash("Категорію видалено", "success")
return redirect(url_for("admin.categories_list"))
@admin_bp.route("/categories/<int:category_id>/move/<direction>", methods=["POST"])
@login_required
def categories_move(category_id, direction):
categories = Category.query.order_by(Category.sort_order).all()
idx = next((i for i, c in enumerate(categories) if c.id == category_id), None)
if idx is not None:
swap_idx = idx - 1 if direction == "up" else idx + 1
if 0 <= swap_idx < len(categories):
categories[idx].sort_order, categories[swap_idx].sort_order = (
categories[swap_idx].sort_order,
categories[idx].sort_order,
)
db.session.commit()
return redirect(url_for("admin.categories_list"))
# ---------------------------------------------------------------------------
# Products
# ---------------------------------------------------------------------------
@admin_bp.route("/products")
@login_required
def products_list():
category_id = request.args.get("category_id", type=int)
query = Product.query
if category_id:
query = query.filter_by(category_id=category_id)
products = query.order_by(Product.category_id, Product.sort_order).all()
categories = Category.query.order_by(Category.sort_order).all()
return render_template(
"admin/products.html", products=products, categories=categories, selected_category=category_id
)
@admin_bp.route("/products/new", methods=["GET", "POST"])
@login_required
def products_new():
categories = Category.query.order_by(Category.sort_order).all()
if request.method == "POST":
product = Product(category_id=request.form.get("category_id", type=int))
_apply_product_form(product)
db.session.add(product)
db.session.commit()
flash("Товар додано", "success")
return redirect(url_for("admin.products_list"))
return render_template("admin/product_form.html", product=None, categories=categories)
@admin_bp.route("/products/<int:product_id>/edit", methods=["GET", "POST"])
@login_required
def products_edit(product_id):
product = Product.query.get_or_404(product_id)
categories = Category.query.order_by(Category.sort_order).all()
if request.method == "POST":
product.category_id = request.form.get("category_id", type=int)
_apply_product_form(product)
db.session.commit()
flash("Товар оновлено", "success")
return redirect(url_for("admin.products_list"))
return render_template("admin/product_form.html", product=product, categories=categories)
@admin_bp.route("/products/<int:product_id>/delete", methods=["POST"])
@login_required
def products_delete(product_id):
product = Product.query.get_or_404(product_id)
db.session.delete(product)
db.session.commit()
flash("Товар видалено", "success")
return redirect(url_for("admin.products_list"))
@admin_bp.route("/products/<int:product_id>/toggle", methods=["POST"])
@login_required
def products_toggle(product_id):
product = Product.query.get_or_404(product_id)
product.is_active = not product.is_active
db.session.commit()
return redirect(url_for("admin.products_list"))
def _apply_product_form(product: Product):
product.name = request.form.get("name", "").strip()
product.description = request.form.get("description", "").strip()
product.weight = request.form.get("weight", "").strip()
product.price = request.form.get("price", type=int) or 0
product.sort_order = request.form.get("sort_order", type=int) or 0
product.is_active = bool(request.form.get("is_active"))
file = request.files.get("image")
if file and file.filename:
ext = file.filename.rsplit(".", 1)[-1].lower()
if ext in ALLOWED_IMAGE_EXT:
filename = f"{uuid.uuid4().hex}.{ext}"
upload_dir = Path(config.UPLOAD_FOLDER)
upload_dir.mkdir(parents=True, exist_ok=True)
file.save(upload_dir / secure_filename(filename))
product.image_url = f"/static/uploads/{filename}"
# ---------------------------------------------------------------------------
# Orders
# ---------------------------------------------------------------------------
@admin_bp.route("/orders")
@login_required
def orders_list():
order_type = request.args.get("type")
status = request.args.get("status")
query = Order.query
if order_type:
query = query.filter_by(order_type=order_type)
if status:
query = query.filter_by(status=status)
orders = query.order_by(Order.created_at.desc()).limit(200).all()
return render_template(
"admin/orders.html", orders=orders, statuses=ORDER_STATUSES, order_type=order_type, status=status
)
@admin_bp.route("/orders/<int:order_id>")
@login_required
def orders_detail(order_id):
order = Order.query.get_or_404(order_id)
return render_template("admin/order_detail.html", order=order, statuses=ORDER_STATUSES)
@admin_bp.route("/orders/<int:order_id>/status", methods=["POST"])
@login_required
def orders_status(order_id):
order = Order.query.get_or_404(order_id)
new_status = request.form.get("status")
if new_status in ORDER_STATUSES:
order.status = new_status
db.session.commit()
flash("Статус замовлення оновлено", "success")
return redirect(url_for("admin.orders_detail", order_id=order_id))
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
@admin_bp.route("/settings", methods=["GET", "POST"])
@login_required
def settings_view():
if request.method == "POST":
for key in DEFAULT_SETTINGS.keys():
if key == "allow_orders_outside_hours":
Setting.set(key, "1" if request.form.get(key) else "0")
else:
Setting.set(key, request.form.get(key, DEFAULT_SETTINGS[key]))
db.session.commit()
flash("Налаштування збережено", "success")
return redirect(url_for("admin.settings_view"))
values = {key: Setting.get(key, default) for key, default in DEFAULT_SETTINGS.items()}
return render_template("admin/settings.html", values=values)
def _slugify(name: str) -> str:
base = "".join(ch.lower() if ch.isalnum() else "-" for ch in name).strip("-")
slug = base or uuid.uuid4().hex[:8]
suffix = 1
candidate = slug
while Category.query.filter_by(slug=candidate).first():
suffix += 1
candidate = f"{slug}-{suffix}"
return candidate

12
bober_bbq/api/__init__.py Normal file
View file

@ -0,0 +1,12 @@
from flask import Blueprint
from bober_bbq.api.cart import cart_bp
from bober_bbq.api.menu import menu_bp
from bober_bbq.api.payments import payments_bp
from bober_bbq.api.settings import settings_bp
api_bp = Blueprint("api", __name__, url_prefix="/api")
api_bp.register_blueprint(menu_bp)
api_bp.register_blueprint(cart_bp)
api_bp.register_blueprint(settings_bp)
api_bp.register_blueprint(payments_bp)

43
bober_bbq/api/auth.py Normal file
View file

@ -0,0 +1,43 @@
"""Shared auth helper: every cart-mutating API call must present a valid
Telegram WebApp initData header so we know which Telegram user it is for."""
from functools import wraps
from flask import current_app, g, jsonify, request
from bober_bbq.extensions import db
from bober_bbq.models import TelegramUser
from bober_bbq.utils.telegram_auth import validate_init_data
def _upsert_user(tg_user: dict) -> TelegramUser:
user = db.session.get(TelegramUser, tg_user["id"])
if user is None:
user = TelegramUser(telegram_id=tg_user["id"])
db.session.add(user)
user.username = tg_user.get("username")
user.first_name = tg_user.get("first_name")
db.session.commit()
return user
def require_telegram_auth(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
init_data = request.headers.get("X-Telegram-Init-Data", "")
data = validate_init_data(init_data)
if data is None and current_app.debug:
# Local dev convenience only: ?dev_user_id=12345 bypasses signature
# verification when no real Telegram WebView is available.
dev_id = request.args.get("dev_user_id")
if dev_id:
data = {"user": {"id": int(dev_id), "first_name": "Dev"}}
if data is None or not data.get("user"):
return jsonify({"error": "invalid or missing Telegram init data"}), 401
g.telegram_user = _upsert_user(data["user"])
return fn(*args, **kwargs)
return wrapper

86
bober_bbq/api/cart.py Normal file
View file

@ -0,0 +1,86 @@
from flask import Blueprint, g, jsonify, request
from bober_bbq.api.auth import require_telegram_auth
from bober_bbq.extensions import db
from bober_bbq.models import CartItem, Product
cart_bp = Blueprint("cart", __name__, url_prefix="/cart")
def _serialize_cart():
items = (
CartItem.query.filter_by(user_id=g.telegram_user.telegram_id)
.join(Product)
.order_by(CartItem.created_at)
.all()
)
return {
"items": [i.to_dict() for i in items],
"items_total": sum(i.line_total for i in items),
"count": sum(i.quantity for i in items),
}
@cart_bp.get("")
@require_telegram_auth
def get_cart():
return jsonify(_serialize_cart())
@cart_bp.post("/items")
@require_telegram_auth
def add_item():
body = request.get_json(silent=True) or {}
product_id = body.get("product_id")
quantity = int(body.get("quantity", 1))
product = Product.query.filter_by(id=product_id, is_active=True).first()
if not product:
return jsonify({"error": "product not found"}), 404
if quantity < 1:
return jsonify({"error": "quantity must be >= 1"}), 400
item = CartItem.query.filter_by(user_id=g.telegram_user.telegram_id, product_id=product_id).first()
if item:
item.quantity += quantity
else:
item = CartItem(user_id=g.telegram_user.telegram_id, product_id=product_id, quantity=quantity)
db.session.add(item)
db.session.commit()
return jsonify(_serialize_cart()), 201
@cart_bp.patch("/items/<int:product_id>")
@require_telegram_auth
def update_item(product_id: int):
body = request.get_json(silent=True) or {}
quantity = int(body.get("quantity", 1))
item = CartItem.query.filter_by(user_id=g.telegram_user.telegram_id, product_id=product_id).first()
if not item:
return jsonify({"error": "item not in cart"}), 404
if quantity <= 0:
db.session.delete(item)
else:
item.quantity = quantity
db.session.commit()
return jsonify(_serialize_cart())
@cart_bp.delete("/items/<int:product_id>")
@require_telegram_auth
def remove_item(product_id: int):
item = CartItem.query.filter_by(user_id=g.telegram_user.telegram_id, product_id=product_id).first()
if item:
db.session.delete(item)
db.session.commit()
return jsonify(_serialize_cart())
@cart_bp.delete("")
@require_telegram_auth
def clear_cart():
CartItem.query.filter_by(user_id=g.telegram_user.telegram_id).delete()
db.session.commit()
return jsonify(_serialize_cart())

17
bober_bbq/api/menu.py Normal file
View file

@ -0,0 +1,17 @@
from flask import Blueprint, jsonify
from bober_bbq.models import Category, Product
menu_bp = Blueprint("menu", __name__)
@menu_bp.get("/categories")
def list_categories():
categories = Category.query.filter_by(is_active=True).order_by(Category.sort_order).all()
return jsonify([c.to_dict() for c in categories])
@menu_bp.get("/products")
def list_products():
products = Product.query.filter_by(is_active=True).order_by(Product.sort_order).all()
return jsonify([p.to_dict() for p in products])

52
bober_bbq/api/payments.py Normal file
View file

@ -0,0 +1,52 @@
import logging
from datetime import datetime, timezone
from flask import Blueprint, jsonify, request
from bober_bbq.extensions import db
from bober_bbq.models import Order, Setting
from bober_bbq.payments.monobank import FAILED_STATUSES, PAID_STATUSES, verify_webhook_signature
from bober_bbq.utils.telegram_notify import send_message
payments_bp = Blueprint("payments", __name__, url_prefix="/payments")
logger = logging.getLogger(__name__)
@payments_bp.post("/monobank/webhook")
def monobank_webhook():
raw_body = request.get_data()
signature = request.headers.get("X-Sign", "")
if not verify_webhook_signature(raw_body, signature):
logger.warning("Rejected monobank webhook: invalid signature")
return jsonify({"error": "invalid signature"}), 400
payload = request.get_json(silent=True) or {}
invoice_id = payload.get("invoiceId")
status = payload.get("status")
if not invoice_id:
return jsonify({"error": "missing invoiceId"}), 400
order = Order.query.filter_by(monobank_invoice_id=invoice_id).first()
if not order:
logger.warning("Monobank webhook for unknown invoice %s", invoice_id)
return jsonify({"ok": True}) # ack anyway, nothing we can do
if status in PAID_STATUSES and order.payment_status != "paid":
order.payment_status = "paid"
order.paid_at = datetime.now(timezone.utc)
db.session.commit()
send_message(order.user_id, f"✅ Оплата замовлення {order.display_number()} пройшла успішно. Дякуємо!")
staff_chat = Setting.get("delivery_chat_id") if order.order_type == "delivery" else Setting.get("pickup_chat_id")
if staff_chat:
send_message(staff_chat, f"💳 Замовлення {order.display_number()} оплачено онлайн (✅).")
elif status in FAILED_STATUSES and order.payment_status != "paid":
send_message(
order.user_id,
f"❌ Оплата замовлення {order.display_number()} не пройшла. "
f"Ви можете спробувати ще раз або оплатити при отриманні.",
)
return jsonify({"ok": True})

26
bober_bbq/api/settings.py Normal file
View file

@ -0,0 +1,26 @@
from flask import Blueprint, jsonify
from bober_bbq.models import Setting
from bober_bbq.utils.workhours import is_open, working_hours
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
@settings_bp.get("")
def public_settings():
start, end = working_hours()
return jsonify(
{
"cafe_name": Setting.get("cafe_name", "Bober BBQ"),
"phone": Setting.get("phone"),
"address": Setting.get("address"),
"work_hours_from": start,
"work_hours_to": end,
"is_open": is_open(),
"instagram_url": Setting.get("instagram_url"),
"maps_url": Setting.get("maps_url"),
"delivery_fee": Setting.get_int("delivery_fee"),
"free_delivery_threshold": Setting.get_int("free_delivery_threshold"),
"pickup_discount_percent": Setting.get_int("pickup_discount_percent"),
}
)

86
bober_bbq/app.py Normal file
View file

@ -0,0 +1,86 @@
import logging
from pathlib import Path
from flask import Flask, jsonify, render_template_string, send_from_directory
from bober_bbq.admin import admin_bp
from bober_bbq.api import api_bp
from bober_bbq.config import config
from bober_bbq.extensions import db, login_manager
from bober_bbq.models import AdminUser
WEBAPP_DIST = Path(__file__).resolve().parent.parent / "webapp" / "dist"
def create_app() -> Flask:
app = Flask(__name__)
app.config.from_object(config)
db.init_app(app)
login_manager.init_app(app)
@login_manager.user_loader
def load_user(user_id):
return db.session.get(AdminUser, int(user_id))
app.register_blueprint(api_bp)
app.register_blueprint(admin_bp)
@app.after_request
def add_dev_cors_headers(response):
# Convenience for `npm run dev` (Vite on a different port) during
# local development. In production the webapp is served from the
# same origin as the API, so this is a no-op there.
if app.debug:
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, X-Telegram-Init-Data"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PATCH, DELETE, OPTIONS"
return response
@app.route("/")
def index():
return jsonify({"service": "Bober BBQ backend", "admin": "/admin", "webapp": "/webapp/", "api": "/api"})
@app.route("/payment/thanks")
def payment_thanks():
return render_template_string(
"""
<!doctype html><html lang="uk"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Дякуємо</title>
<style>
body{background:#14100e;color:#f2e9e0;font-family:sans-serif;display:flex;
align-items:center;justify-content:center;height:100vh;margin:0;text-align:center;}
div{padding:24px;}
a{color:#f97316;}
</style></head>
<body><div>
<h1>Дякуємо за оплату!</h1>
<p>Можете повернутись у Telegram-бот статус замовлення оновиться автоматично.</p>
</div></body></html>
"""
)
# Serve the built Mini App (Vite `npm run build` output) under /webapp/
@app.route("/webapp/")
@app.route("/webapp/<path:path>")
def webapp(path="index.html"):
if not WEBAPP_DIST.exists():
return (
"Web app is not built yet. Run `npm install && npm run build` inside the webapp/ folder.",
404,
)
full_path = WEBAPP_DIST / path
if not full_path.exists():
path = "index.html" # SPA fallback
return send_from_directory(WEBAPP_DIST, path)
if not app.debug:
logging.basicConfig(level=logging.INFO)
return app
if __name__ == "__main__":
application = create_app()
application.run(debug=True, port=8000)

51
bober_bbq/config.py Normal file
View file

@ -0,0 +1,51 @@
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
INSTANCE_DIR = BASE_DIR / "instance"
INSTANCE_DIR.mkdir(exist_ok=True)
def _split_ids(raw: str) -> list[int]:
ids = []
for part in (raw or "").split(","):
part = part.strip()
if part:
try:
ids.append(int(part))
except ValueError:
pass
return ids
class Config:
BOT_TOKEN = os.getenv("BOT_TOKEN", "")
OWNER_IDS = _split_ids(os.getenv("OWNER_IDS", ""))
DELIVERY_CHAT_ID = int(os.getenv("DELIVERY_CHAT_ID")) if os.getenv("DELIVERY_CHAT_ID") else None
PICKUP_CHAT_ID = int(os.getenv("PICKUP_CHAT_ID")) if os.getenv("PICKUP_CHAT_ID") else None
WEBAPP_URL = os.getenv("WEBAPP_URL", "")
BACKEND_PUBLIC_URL = os.getenv("BACKEND_PUBLIC_URL", "http://localhost:8000")
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL") or f"sqlite:///{INSTANCE_DIR / 'bober_bbq.db'}"
SQLALCHEMY_TRACK_MODIFICATIONS = False
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me")
FLASK_ADMIN_USERNAME = os.getenv("FLASK_ADMIN_USERNAME", "admin")
FLASK_ADMIN_PASSWORD = os.getenv("FLASK_ADMIN_PASSWORD", "admin")
MONOBANK_TOKEN = os.getenv("MONOBANK_TOKEN", "")
MONOBANK_WEBHOOK_URL = os.getenv("MONOBANK_WEBHOOK_URL", "")
TIMEZONE = os.getenv("TIMEZONE", "Europe/Kyiv")
MAX_CONTENT_LENGTH = 8 * 1024 * 1024 # 8 MB uploads
UPLOAD_FOLDER = BASE_DIR / "bober_bbq" / "static" / "uploads"
config = Config()

6
bober_bbq/extensions.py Normal file
View file

@ -0,0 +1,6 @@
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
login_manager = LoginManager()
login_manager.login_view = "admin.login"

246
bober_bbq/models.py Normal file
View file

@ -0,0 +1,246 @@
from datetime import datetime, timezone
from flask_login import UserMixin
from sqlalchemy import UniqueConstraint
from werkzeug.security import check_password_hash, generate_password_hash
from bober_bbq.extensions import db
def utcnow() -> datetime:
return datetime.now(timezone.utc)
# ---------------------------------------------------------------------------
# Telegram users
# ---------------------------------------------------------------------------
class TelegramUser(db.Model):
__tablename__ = "telegram_users"
telegram_id = db.Column(db.BigInteger, primary_key=True)
username = db.Column(db.String(64))
first_name = db.Column(db.String(128))
phone = db.Column(db.String(32))
created_at = db.Column(db.DateTime, default=utcnow)
cart_items = db.relationship("CartItem", backref="user", cascade="all, delete-orphan")
orders = db.relationship("Order", backref="user")
def cart_total(self) -> int:
return sum(item.line_total for item in self.cart_items)
# ---------------------------------------------------------------------------
# Menu: categories & products
# ---------------------------------------------------------------------------
class Category(db.Model):
__tablename__ = "categories"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), nullable=False)
slug = db.Column(db.String(64), unique=True, nullable=False)
sort_order = db.Column(db.Integer, default=0, nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
products = db.relationship(
"Product", backref="category", order_by="Product.sort_order", cascade="all, delete-orphan"
)
def to_dict(self):
return {"id": self.id, "name": self.name, "slug": self.slug, "sort_order": self.sort_order}
class Product(db.Model):
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=False)
name = db.Column(db.String(128), nullable=False)
description = db.Column(db.Text, default="")
weight = db.Column(db.String(32), default="") # e.g. "300 г" or "0.5 л"
price = db.Column(db.Integer, nullable=False) # UAH, whole hryvnias
image_url = db.Column(db.String(256), default="")
is_active = db.Column(db.Boolean, default=True, nullable=False)
sort_order = db.Column(db.Integer, default=0, nullable=False)
def to_dict(self):
return {
"id": self.id,
"category_id": self.category_id,
"name": self.name,
"description": self.description,
"weight": self.weight,
"price": self.price,
"image_url": self.image_url,
}
# ---------------------------------------------------------------------------
# Cart — single shared cart per Telegram user, used by both bot and webapp
# ---------------------------------------------------------------------------
class CartItem(db.Model):
__tablename__ = "cart_items"
__table_args__ = (UniqueConstraint("user_id", "product_id", name="uq_cart_user_product"),)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.BigInteger, db.ForeignKey("telegram_users.telegram_id"), nullable=False)
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False)
quantity = db.Column(db.Integer, default=1, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow)
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow)
product = db.relationship("Product")
@property
def line_total(self) -> int:
return self.product.price * self.quantity
def to_dict(self):
return {
"product_id": self.product_id,
"name": self.product.name,
"weight": self.product.weight,
"image_url": self.product.image_url,
"price": self.product.price,
"quantity": self.quantity,
"line_total": self.line_total,
}
# ---------------------------------------------------------------------------
# Orders
# ---------------------------------------------------------------------------
ORDER_TYPES = ("delivery", "pickup")
ORDER_STATUSES = ("new", "confirmed", "cooking", "ready", "completed", "cancelled")
PAYMENT_METHODS = ("cash", "card")
PAYMENT_STATUSES = ("unpaid", "paid")
class Order(db.Model):
__tablename__ = "orders"
id = db.Column(db.Integer, primary_key=True)
number = db.Column(db.Integer, unique=True) # human-friendly order number, set after insert
user_id = db.Column(db.BigInteger, db.ForeignKey("telegram_users.telegram_id"), nullable=False)
order_type = db.Column(db.String(16), nullable=False) # delivery | pickup
status = db.Column(db.String(16), default="new", nullable=False)
customer_name = db.Column(db.String(128))
customer_phone = db.Column(db.String(32))
# delivery-specific
address_city = db.Column(db.String(64))
address_street = db.Column(db.String(128))
address_house = db.Column(db.String(16))
address_apartment = db.Column(db.String(16))
address_comment = db.Column(db.String(256))
# pickup-specific
pickup_time = db.Column(db.String(32))
payment_method = db.Column(db.String(16)) # cash | card
cash_change_for = db.Column(db.Integer) # banknote value, null = exact change / not applicable
payment_status = db.Column(db.String(16), default="unpaid", nullable=False)
monobank_invoice_id = db.Column(db.String(64))
monobank_page_url = db.Column(db.String(512))
items_total = db.Column(db.Integer, default=0, nullable=False)
delivery_fee = db.Column(db.Integer, default=0, nullable=False)
discount_amount = db.Column(db.Integer, default=0, nullable=False)
total = db.Column(db.Integer, default=0, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow)
paid_at = db.Column(db.DateTime)
items = db.relationship("OrderItem", backref="order", cascade="all, delete-orphan")
def display_number(self) -> str:
return f"{self.number}" if self.number else f"#{self.id}"
class OrderItem(db.Model):
__tablename__ = "order_items"
id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False)
product_id = db.Column(db.Integer, db.ForeignKey("products.id"))
product_name = db.Column(db.String(128), nullable=False) # snapshot, survives product edits/deletes
product_price = db.Column(db.Integer, nullable=False)
quantity = db.Column(db.Integer, nullable=False)
@property
def line_total(self) -> int:
return self.product_price * self.quantity
# ---------------------------------------------------------------------------
# Key/value settings, editable from the admin panel
# ---------------------------------------------------------------------------
class Setting(db.Model):
__tablename__ = "settings"
key = db.Column(db.String(64), primary_key=True)
value = db.Column(db.Text, default="")
@staticmethod
def get(key: str, default: str = "") -> str:
row = db.session.get(Setting, key)
return row.value if row and row.value is not None else default
@staticmethod
def get_int(key: str, default: int = 0) -> int:
try:
return int(Setting.get(key, str(default)))
except (TypeError, ValueError):
return default
@staticmethod
def get_bool(key: str, default: bool = False) -> bool:
val = Setting.get(key, "1" if default else "0")
return val in ("1", "true", "True")
@staticmethod
def set(key: str, value):
row = db.session.get(Setting, key)
if row is None:
row = Setting(key=key, value=str(value))
db.session.add(row)
else:
row.value = str(value)
DEFAULT_SETTINGS = {
"cafe_name": "Bober BBQ",
"phone": "+380 XX XXX XX XX",
"address": "м. Київ, вул. Прикладна, 1",
"work_hours_from": "11:00",
"work_hours_to": "21:00",
"allow_orders_outside_hours": "0",
"instagram_url": "",
"maps_url": "",
"delivery_fee": "60",
"free_delivery_threshold": "500",
"pickup_discount_percent": "15",
"delivery_chat_id": "",
"pickup_chat_id": "",
"order_number_start": "1000",
}
# ---------------------------------------------------------------------------
# Admin panel auth
# ---------------------------------------------------------------------------
class AdminUser(UserMixin, db.Model):
__tablename__ = "admin_users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
def set_password(self, password: str):
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)

View file

View file

@ -0,0 +1,120 @@
"""Monobank Merchant (Acquiring) API integration.
Docs: https://monobank.ua/api-docs/acquiring/
Requires MONOBANK_TOKEN in .env the merchant acquiring token issued to the
client's FOP account (see ТЗ section 19 / 22.8). Until that token is
provided, `create_invoice` raises MonobankNotConfigured so the bot can fall
back to "pay on delivery/pickup" instead of crashing.
"""
import base64
import logging
import requests
from bober_bbq.config import config
from bober_bbq.models import Order
logger = logging.getLogger(__name__)
API_BASE = "https://api.monobank.ua"
CREATE_INVOICE_URL = f"{API_BASE}/api/merchant/invoice/create"
INVOICE_STATUS_URL = f"{API_BASE}/api/merchant/invoice/status"
PUBLIC_KEY_URL = f"{API_BASE}/api/merchant/pubkey"
_pubkey_cache: bytes | None = None
class MonobankNotConfigured(RuntimeError):
pass
class MonobankError(RuntimeError):
pass
def _headers() -> dict:
if not config.MONOBANK_TOKEN:
raise MonobankNotConfigured("MONOBANK_TOKEN is not set — ask the client for FOP acquiring credentials")
return {"X-Token": config.MONOBANK_TOKEN, "Content-Type": "application/json"}
def create_invoice(order: Order) -> dict:
"""Create a payment invoice for the given order and return
{"invoiceId": ..., "pageUrl": ...}. Raises MonobankNotConfigured /
MonobankError on failure."""
basket = [
{
"name": item.product_name,
"qty": item.quantity,
"sum": item.product_price * 100,
"unit": "шт.",
}
for item in order.items
]
payload = {
"amount": order.total * 100,
"ccy": 980,
"merchantPaymInfo": {
"reference": order.display_number(),
"destination": f"Оплата замовлення {order.display_number()} Bober BBQ",
"basketOrder": basket,
},
"redirectUrl": f"{config.BACKEND_PUBLIC_URL}/payment/thanks?order={order.id}",
"webHookUrl": config.MONOBANK_WEBHOOK_URL or f"{config.BACKEND_PUBLIC_URL}/api/payments/monobank/webhook",
"validity": 3600,
}
resp = requests.post(CREATE_INVOICE_URL, json=payload, headers=_headers(), timeout=15)
if resp.status_code != 200:
logger.error("Monobank create_invoice failed: %s %s", resp.status_code, resp.text)
raise MonobankError(f"Monobank invoice creation failed: {resp.status_code}")
return resp.json()
def get_invoice_status(invoice_id: str) -> dict:
resp = requests.get(INVOICE_STATUS_URL, params={"invoiceId": invoice_id}, headers=_headers(), timeout=15)
if resp.status_code != 200:
logger.error("Monobank get_invoice_status failed: %s %s", resp.status_code, resp.text)
raise MonobankError(f"Monobank status check failed: {resp.status_code}")
return resp.json()
def _fetch_public_key() -> bytes:
global _pubkey_cache
if _pubkey_cache is not None:
return _pubkey_cache
resp = requests.get(PUBLIC_KEY_URL, headers=_headers(), timeout=15)
resp.raise_for_status()
key_b64 = resp.json()["key"]
_pubkey_cache = base64.b64decode(key_b64)
return _pubkey_cache
def verify_webhook_signature(raw_body: bytes, signature_b64: str) -> bool:
"""Verify the `X-Sign` header monobank attaches to webhook requests
(ECDSA/SHA256 over the raw JSON body, public key served by monobank)."""
try:
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
public_key = serialization.load_der_public_key(_fetch_public_key())
signature = base64.b64decode(signature_b64)
public_key.verify(signature, raw_body, ec.ECDSA(hashes.SHA256()))
return True
except MonobankNotConfigured:
logger.warning("Cannot verify monobank webhook signature: MONOBANK_TOKEN not set")
return False
except InvalidSignature:
return False
except Exception:
logger.exception("Monobank webhook signature verification error")
return False
# Maps monobank invoice status -> our internal payment_status
PAID_STATUSES = {"success"}
FAILED_STATUSES = {"failure", "reversed", "expired"}

95
bober_bbq/seed.py Normal file
View file

@ -0,0 +1,95 @@
"""Idempotent seed data: categories, sample products, default settings, admin user.
Run with: python -m bober_bbq.seed
"""
from bober_bbq.config import config
from bober_bbq.extensions import db
from bober_bbq.models import DEFAULT_SETTINGS, AdminUser, Category, Product, Setting
CATEGORIES = [
("Шашлик", "shashlyk"),
("BBQ", "bbq"),
("Піца", "pizza"),
("Бургери", "burgers"),
("Гарніри", "sides"),
("Закуски", "snacks"),
("Соуси", "sauces"),
("Напої", "drinks"),
]
PRODUCTS = {
"shashlyk": [
("Шашлик зі свинини", "Соковитий шашлик зі свинячої шийки на мангалі", "300 г", 160),
("Шашлик з курки", "Ніжний шашлик з курячого філе", "300 г", 140),
("Люля-кебаб", "Кебаб з рубленого м'яса на шпажці, приправлений спеціями", "200 г", 150),
],
"bbq": [
("Крильця BBQ", "Курячі крильця в фірмовому BBQ-соусі", "250 г", 130),
("Реберця BBQ", "Свинячі реберця, запечені в соусі BBQ", "350 г", 210),
],
"pizza": [
("Піца Пепероні", "Томатний соус, моцарела, пепероні", "32 см", 280),
("Піца Маргарита", "Томатний соус, моцарела, базилік", "32 см", 220),
],
"burgers": [
("Бургер BBQ", "Яловича котлета, бекон, соус BBQ, чеддер", "280 г", 180),
],
"sides": [
("Картопля фрі", "Хрустка картопля фрі з сіллю", "200 г", 70),
],
"snacks": [
("Сирні палички", "Панірувані сирні палички з соусом", "150 г", 90),
],
"sauces": [
("Соус BBQ", "Фірмовий соус барбекю", "50 г", 25),
("Соус часниковий", "Домашній часниковий соус", "50 г", 25),
],
"drinks": [
("Coca-Cola", "Газований напій", "0.5 л", 45),
("Вода негазована", "", "0.5 л", 25),
],
}
def seed():
for i, (name, slug) in enumerate(CATEGORIES):
cat = Category.query.filter_by(slug=slug).first()
if not cat:
cat = Category(name=name, slug=slug, sort_order=i)
db.session.add(cat)
db.session.flush()
for j, (pname, pdesc, weight, price) in enumerate(PRODUCTS.get(slug, [])):
exists = Product.query.filter_by(category_id=cat.id, name=pname).first()
if not exists:
db.session.add(
Product(
category_id=cat.id,
name=pname,
description=pdesc,
weight=weight,
price=price,
sort_order=j,
)
)
for key, value in DEFAULT_SETTINGS.items():
if db.session.get(Setting, key) is None:
db.session.add(Setting(key=key, value=value))
if not AdminUser.query.filter_by(username=config.FLASK_ADMIN_USERNAME).first():
admin = AdminUser(username=config.FLASK_ADMIN_USERNAME)
admin.set_password(config.FLASK_ADMIN_PASSWORD)
db.session.add(admin)
db.session.commit()
if __name__ == "__main__":
from bober_bbq.app import create_app
app = create_app()
with app.app_context():
db.create_all()
seed()
print("Seed complete.")

View file

@ -0,0 +1,140 @@
:root {
--bg: #14100e;
--panel: #1d1712;
--panel-2: #241b15;
--border: #33261c;
--text: #f2e9e0;
--muted: #b8a99a;
--accent: #f97316;
--accent-dark: #c2570a;
--success: #22c55e;
--error: #ef4444;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, Segoe UI, Roboto, Arial, sans-serif;
font-size: 15px;
}
a { color: inherit; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px;
background: var(--panel);
border-right: 1px solid var(--border);
padding: 20px 12px;
flex-shrink: 0;
}
.brand {
font-weight: 800;
font-size: 20px;
color: var(--accent);
padding: 0 8px 20px;
letter-spacing: 0.5px;
}
.nav a {
display: block;
padding: 10px 12px;
border-radius: var(--radius);
text-decoration: none;
color: var(--muted);
margin-bottom: 4px;
font-weight: 500;
}
.nav a:hover { background: var(--panel-2); color: var(--text); }
.nav a.active { background: var(--accent); color: #1b0f00; }
.content { flex: 1; padding: 28px 36px; max-width: 1100px; }
h1 { font-size: 22px; margin: 0 0 20px; }
h2 { font-size: 17px; margin: 24px 0 12px; color: var(--muted); }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 20px;
}
.stats { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 24px; }
.stat {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
min-width: 140px;
}
.stat .num { font-size: 26px; font-weight: 700; color: var(--accent); }
.stat .label { color: var(--muted); font-size: 13px; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 14px; }
th { color: var(--muted); font-weight: 600; text-transform: uppercase; font-size: 11px; letter-spacing: 0.5px; }
tr:hover td { background: var(--panel-2); }
.btn {
display: inline-block;
background: var(--accent);
color: #1b0f00;
border: none;
padding: 9px 16px;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
text-decoration: none;
font-size: 14px;
}
.btn:hover { background: var(--accent-dark); }
.btn.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
.btn.danger { background: var(--error); color: #fff; }
.btn.small { padding: 5px 10px; font-size: 12px; }
input[type=text], input[type=number], input[type=password], input[type=file], select, textarea {
width: 100%;
padding: 9px 11px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
font-size: 14px;
margin-bottom: 12px;
}
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
.row { display: flex; gap: 16px; }
.row > div { flex: 1; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; }
.badge.new { background: #3b82f620; color: #60a5fa; }
.badge.confirmed { background: #f9731620; color: var(--accent); }
.badge.cooking { background: #eab30820; color: #eab308; }
.badge.ready { background: #22c55e20; color: var(--success); }
.badge.completed { background: #22c55e40; color: var(--success); }
.badge.cancelled { background: #ef444420; color: var(--error); }
.badge.paid { background: #22c55e20; color: var(--success); }
.badge.unpaid { background: #ef444420; color: var(--error); }
.flash { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
.flash.success { background: #22c55e20; color: var(--success); }
.flash.error { background: #ef444420; color: var(--error); }
.login-wrap { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box { width: 320px; }
.thumb { width: 44px; height: 44px; border-radius: 8px; object-fit: cover; background: var(--panel-2); }
.muted { color: var(--muted); }
.filters { display: flex; gap: 10px; margin-bottom: 16px; }
.filters select { width: auto; margin-bottom: 0; }
.filters a.btn { padding: 9px 14px; }

View file

View file

@ -0,0 +1,36 @@
<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Bober BBQ — Адмін{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='admin/css/admin.css') }}">
</head>
<body>
{% if current_user.is_authenticated %}
<div class="layout">
<div class="sidebar">
<div class="brand">🦫 Bober BBQ</div>
<nav class="nav">
<a href="{{ url_for('admin.dashboard') }}" class="{{ 'active' if request.endpoint == 'admin.dashboard' }}">Дашборд</a>
<a href="{{ url_for('admin.orders_list') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('admin.orders') }}">Замовлення</a>
<a href="{{ url_for('admin.products_list') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('admin.products') }}">Товари</a>
<a href="{{ url_for('admin.categories_list') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('admin.categories') }}">Категорії</a>
<a href="{{ url_for('admin.settings_view') }}" class="{{ 'active' if request.endpoint == 'admin.settings_view' }}">Налаштування</a>
<a href="{{ url_for('admin.logout') }}" style="margin-top:20px;">Вийти</a>
</nav>
</div>
<div class="content">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</div>
{% else %}
{% block guest_content %}{% endblock %}
{% endif %}
</body>
</html>

View file

@ -0,0 +1,47 @@
{% extends "admin/base.html" %}
{% block content %}
<h1>Категорії</h1>
<div class="card">
<table>
<tr><th>Порядок</th><th>Назва</th><th>Slug</th><th>Активна</th><th></th></tr>
{% for c in categories %}
<tr>
<td>
<form method="post" action="{{ url_for('admin.categories_move', category_id=c.id, direction='up') }}" style="display:inline;"><button class="btn small secondary" type="submit"></button></form>
<form method="post" action="{{ url_for('admin.categories_move', category_id=c.id, direction='down') }}" style="display:inline;"><button class="btn small secondary" type="submit"></button></form>
</td>
<td>
<form method="post" action="{{ url_for('admin.categories_edit', category_id=c.id) }}" style="display:flex;gap:8px;align-items:center;">
<input type="text" name="name" value="{{ c.name }}" style="margin:0;width:180px;">
<label style="display:flex;align-items:center;gap:6px;margin:0;">
<input type="checkbox" name="is_active" {{ 'checked' if c.is_active }} style="width:auto;margin:0;"> активна
</label>
<button class="btn small" type="submit">Зберегти</button>
</form>
</td>
<td class="muted">{{ c.slug }}</td>
<td>{{ '✅' if c.is_active else '—' }}</td>
<td>
<form method="post" action="{{ url_for('admin.categories_delete', category_id=c.id) }}" onsubmit="return confirm('Видалити категорію разом з товарами?');">
<button class="btn small danger" type="submit">Видалити</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">Категорій ще немає</td></tr>
{% endfor %}
</table>
</div>
<h2>Додати категорію</h2>
<div class="card">
<form method="post" action="{{ url_for('admin.categories_new') }}" style="display:flex;gap:10px;align-items:flex-end;">
<div style="flex:1;">
<label>Назва</label>
<input type="text" name="name" required style="margin:0;">
</div>
<button class="btn" type="submit">Додати</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "admin/base.html" %}
{% block content %}
<h1>Дашборд</h1>
<div class="stats">
<div class="stat"><div class="num">{{ stats.new_orders }}</div><div class="label">Нові замовлення</div></div>
<div class="stat"><div class="num">{{ stats.total_orders }}</div><div class="label">Усього замовлень</div></div>
<div class="stat"><div class="num">{{ stats.products }}</div><div class="label">Товарів</div></div>
<div class="stat"><div class="num">{{ stats.categories }}</div><div class="label">Категорій</div></div>
</div>
<h2>Останні замовлення</h2>
<div class="card">
<table>
<tr><th></th><th>Тип</th><th>Клієнт</th><th>Сума</th><th>Оплата</th><th>Статус</th><th>Час</th><th></th></tr>
{% for o in orders %}
<tr>
<td>{{ o.display_number() }}</td>
<td>{{ 'Доставка' if o.order_type == 'delivery' else 'Самовивіз' }}</td>
<td>{{ o.customer_name }}</td>
<td>{{ o.total }} грн</td>
<td><span class="badge {{ o.payment_status }}">{{ 'Оплачено' if o.payment_status == 'paid' else 'Не оплачено' }}</span></td>
<td><span class="badge {{ o.status }}">{{ o.status }}</span></td>
<td>{{ o.created_at.strftime('%d.%m %H:%M') if o.created_at else '' }}</td>
<td><a class="btn small secondary" href="{{ url_for('admin.orders_detail', order_id=o.id) }}">Відкрити</a></td>
</tr>
{% else %}
<tr><td colspan="8" class="muted">Замовлень ще немає</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "admin/base.html" %}
{% block guest_content %}
<div class="login-wrap">
<div class="card login-box">
<div class="brand" style="text-align:center;margin-bottom:16px;">🦫 Bober BBQ</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="post">
<label>Логін</label>
<input type="text" name="username" autofocus>
<label>Пароль</label>
<input type="password" name="password">
<button class="btn" type="submit" style="width:100%;">Увійти</button>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,58 @@
{% extends "admin/base.html" %}
{% block content %}
<h1>Замовлення {{ order.display_number() }}</h1>
<div class="card">
<table>
<tr><td class="muted">Тип</td><td>{{ 'Доставка' if order.order_type == 'delivery' else 'Самовивіз' }}</td></tr>
<tr><td class="muted">Клієнт</td><td>{{ order.customer_name }}</td></tr>
<tr><td class="muted">Телефон</td><td>{{ order.customer_phone }}</td></tr>
{% if order.order_type == 'delivery' %}
<tr><td class="muted">Адреса</td><td>
{{ order.address_city }}{% if order.address_street %}, вул. {{ order.address_street }}{% endif %}{% if order.address_house %}, буд. {{ order.address_house }}{% endif %}{% if order.address_apartment %}, кв. {{ order.address_apartment }}{% endif %}
{% if order.address_comment %}<br><span class="muted">{{ order.address_comment }}</span>{% endif %}
</td></tr>
{% else %}
<tr><td class="muted">Час прибуття</td><td>{{ order.pickup_time }}</td></tr>
{% endif %}
<tr><td class="muted">Метод оплати</td><td>{{ 'Картка' if order.payment_method == 'card' else 'Готівка' }}
{% if order.order_type == 'delivery' and order.payment_method == 'cash' and order.cash_change_for %} (решта з {{ order.cash_change_for }} грн){% endif %}
</td></tr>
<tr><td class="muted">Оплата</td><td><span class="badge {{ order.payment_status }}">{{ 'Оплачено' if order.payment_status == 'paid' else 'Не оплачено' }}</span></td></tr>
<tr><td class="muted">Створено</td><td>{{ order.created_at.strftime('%d.%m.%Y %H:%M') if order.created_at else '' }}</td></tr>
</table>
</div>
<h2>Товари</h2>
<div class="card">
<table>
<tr><th>Товар</th><th>К-сть</th><th>Ціна</th><th>Сума</th></tr>
{% for item in order.items %}
<tr>
<td>{{ item.product_name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.product_price }} грн</td>
<td>{{ item.line_total }} грн</td>
</tr>
{% endfor %}
</table>
<div style="margin-top:12px;text-align:right;">
<div class="muted">Сума товарів: {{ order.items_total }} грн</div>
{% if order.delivery_fee %}<div class="muted">Доставка: {{ order.delivery_fee }} грн</div>{% endif %}
{% if order.discount_amount %}<div class="muted">Знижка: {{ order.discount_amount }} грн</div>{% endif %}
<div style="font-weight:700;font-size:17px;margin-top:4px;">Разом: {{ order.total }} грн</div>
</div>
</div>
<h2>Статус</h2>
<div class="card">
<form method="post" action="{{ url_for('admin.orders_status', order_id=order.id) }}" style="display:flex;gap:10px;align-items:center;">
<select name="status" style="width:auto;margin:0;">
{% for s in statuses %}
<option value="{{ s }}" {{ 'selected' if order.status == s }}>{{ s }}</option>
{% endfor %}
</select>
<button class="btn" type="submit">Оновити статус</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,41 @@
{% extends "admin/base.html" %}
{% block content %}
<h1>Замовлення</h1>
<div class="filters">
<form method="get" style="display:flex;gap:10px;">
<select name="type" onchange="this.form.submit()">
<option value="">Усі типи</option>
<option value="delivery" {{ 'selected' if order_type == 'delivery' }}>Доставка</option>
<option value="pickup" {{ 'selected' if order_type == 'pickup' }}>Самовивіз</option>
</select>
<select name="status" onchange="this.form.submit()">
<option value="">Усі статуси</option>
{% for s in statuses %}
<option value="{{ s }}" {{ 'selected' if status == s }}>{{ s }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="card">
<table>
<tr><th></th><th>Тип</th><th>Клієнт</th><th>Телефон</th><th>Сума</th><th>Оплата</th><th>Статус</th><th>Час</th><th></th></tr>
{% for o in orders %}
<tr>
<td>{{ o.display_number() }}</td>
<td>{{ 'Доставка' if o.order_type == 'delivery' else 'Самовивіз' }}</td>
<td>{{ o.customer_name }}</td>
<td>{{ o.customer_phone }}</td>
<td>{{ o.total }} грн</td>
<td><span class="badge {{ o.payment_status }}">{{ 'Оплачено' if o.payment_status == 'paid' else 'Не оплачено' }}</span></td>
<td><span class="badge {{ o.status }}">{{ o.status }}</span></td>
<td>{{ o.created_at.strftime('%d.%m %H:%M') if o.created_at else '' }}</td>
<td><a class="btn small secondary" href="{{ url_for('admin.orders_detail', order_id=o.id) }}">Відкрити</a></td>
</tr>
{% else %}
<tr><td colspan="9" class="muted">Немає замовлень за цим фільтром</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}

View file

@ -0,0 +1,51 @@
{% extends "admin/base.html" %}
{% block content %}
<h1>{{ 'Редагувати товар' if product else 'Новий товар' }}</h1>
<div class="card" style="max-width:520px;">
<form method="post" enctype="multipart/form-data">
<label>Категорія</label>
<select name="category_id">
{% for c in categories %}
<option value="{{ c.id }}" {{ 'selected' if product and product.category_id == c.id }}>{{ c.name }}</option>
{% endfor %}
</select>
<label>Назва</label>
<input type="text" name="name" value="{{ product.name if product else '' }}" required>
<label>Опис</label>
<textarea name="description" rows="3">{{ product.description if product else '' }}</textarea>
<div class="row">
<div>
<label>Вага / обʼєм (напр. 300 г)</label>
<input type="text" name="weight" value="{{ product.weight if product else '' }}">
</div>
<div>
<label>Ціна, грн</label>
<input type="number" name="price" value="{{ product.price if product else '' }}" required min="0">
</div>
</div>
<label>Фото</label>
{% if product and product.image_url %}
<img class="thumb" src="{{ product.image_url }}" style="width:80px;height:80px;margin-bottom:8px;">
{% endif %}
<input type="file" name="image" accept="image/*">
<label>Порядок сортування</label>
<input type="number" name="sort_order" value="{{ product.sort_order if product else 0 }}">
<label style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" name="is_active" style="width:auto;" {{ 'checked' if not product or product.is_active }}>
Активний (показувати в меню)
</label>
<div style="margin-top:16px;display:flex;gap:10px;">
<button class="btn" type="submit">Зберегти</button>
<a class="btn secondary" href="{{ url_for('admin.products_list') }}">Скасувати</a>
</div>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,43 @@
{% extends "admin/base.html" %}
{% block content %}
<h1>Товари</h1>
<div class="filters">
<form method="get">
<select name="category_id" onchange="this.form.submit()">
<option value="">Усі категорії</option>
{% for c in categories %}
<option value="{{ c.id }}" {{ 'selected' if selected_category == c.id }}>{{ c.name }}</option>
{% endfor %}
</select>
</form>
<a class="btn" href="{{ url_for('admin.products_new') }}">+ Новий товар</a>
</div>
<div class="card">
<table>
<tr><th></th><th>Назва</th><th>Категорія</th><th>Вага</th><th>Ціна</th><th>Активний</th><th></th></tr>
{% for p in products %}
<tr>
<td>{% if p.image_url %}<img class="thumb" src="{{ p.image_url }}">{% else %}<div class="thumb"></div>{% endif %}</td>
<td>{{ p.name }}</td>
<td class="muted">{{ p.category.name }}</td>
<td>{{ p.weight }}</td>
<td>{{ p.price }} грн</td>
<td>{{ '✅' if p.is_active else '🚫' }}</td>
<td style="white-space:nowrap;">
<a class="btn small secondary" href="{{ url_for('admin.products_edit', product_id=p.id) }}">Редагувати</a>
<form method="post" action="{{ url_for('admin.products_toggle', product_id=p.id) }}" style="display:inline;">
<button class="btn small secondary" type="submit">{{ 'Приховати' if p.is_active else 'Показати' }}</button>
</form>
<form method="post" action="{{ url_for('admin.products_delete', product_id=p.id) }}" style="display:inline;" onsubmit="return confirm('Видалити товар?');">
<button class="btn small danger" type="submit">Видалити</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">Товарів ще немає</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}

View file

@ -0,0 +1,45 @@
{% extends "admin/base.html" %}
{% block content %}
<h1>Налаштування</h1>
<div class="card" style="max-width:560px;">
<form method="post">
<h2 style="margin-top:0;">Заклад</h2>
<label>Назва</label>
<input type="text" name="cafe_name" value="{{ values.cafe_name }}">
<label>Телефон</label>
<input type="text" name="phone" value="{{ values.phone }}">
<label>Адреса</label>
<input type="text" name="address" value="{{ values.address }}">
<div class="row">
<div><label>Графік з</label><input type="text" name="work_hours_from" value="{{ values.work_hours_from }}" placeholder="11:00"></div>
<div><label>Графік до</label><input type="text" name="work_hours_to" value="{{ values.work_hours_to }}" placeholder="21:00"></div>
</div>
<label style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" name="allow_orders_outside_hours" style="width:auto;" {{ 'checked' if values.allow_orders_outside_hours == '1' }}>
Приймати замовлення поза графіком (виконання під час роботи)
</label>
<label>Instagram (посилання)</label>
<input type="text" name="instagram_url" value="{{ values.instagram_url }}">
<label>Google Maps (посилання)</label>
<input type="text" name="maps_url" value="{{ values.maps_url }}">
<h2>Доставка та самовивіз</h2>
<div class="row">
<div><label>Вартість доставки, грн</label><input type="number" name="delivery_fee" value="{{ values.delivery_fee }}"></div>
<div><label>Безкоштовна доставка від, грн</label><input type="number" name="free_delivery_threshold" value="{{ values.free_delivery_threshold }}"></div>
</div>
<label>Знижка на самовивіз, %</label>
<input type="number" name="pickup_discount_percent" value="{{ values.pickup_discount_percent }}">
<h2>Telegram-чати для замовлень</h2>
<label>Chat ID — Доставка</label>
<input type="text" name="delivery_chat_id" value="{{ values.delivery_chat_id }}" placeholder="-1001234567890">
<label>Chat ID — Самовивіз</label>
<input type="text" name="pickup_chat_id" value="{{ values.pickup_chat_id }}" placeholder="-1001234567890">
<p class="muted" style="font-size:13px;">Бот має бути доданий у ці чати як адміністратор. ID чату можна дізнатись, переславши будь-яке повідомлення з чату боту <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> або аналогічному.</p>
<button class="btn" type="submit" style="margin-top:8px;">Зберегти</button>
</form>
</div>
{% endblock %}

View file

View file

@ -0,0 +1,79 @@
"""Renders the order notification message sent to the manager/staff chats,
following the exact layout specified in the ТЗ (section 11)."""
from datetime import datetime
from zoneinfo import ZoneInfo
from bober_bbq.config import config
from bober_bbq.models import Order
_tz = ZoneInfo(config.TIMEZONE)
def _items_lines(order: Order) -> str:
lines = []
for item in order.items:
lines.append(f"{item.product_name} × {item.quantity}{item.line_total} грн")
return "\n".join(lines)
def _payment_lines(order: Order) -> str:
paid = "" if order.payment_status == "paid" else ""
method = "Картка" if order.payment_method == "card" else "Готівка"
lines = [f"Оплачено: {paid}", f"Метод оплати: {method}"]
if order.order_type == "delivery":
change = f"{order.cash_change_for} грн" if order.cash_change_for else ""
lines.append(f"Номінал купюри: {change}")
return "\n".join(lines)
def format_delivery_order(order: Order) -> str:
address = f"{order.address_city + ', ' if order.address_city else ''}"
address += f"вул. {order.address_street}" if order.address_street else ""
if order.address_house:
address += f", буд. {order.address_house}"
if order.address_apartment:
address += f", кв. {order.address_apartment}"
if order.address_comment:
address += f" ({order.address_comment})"
created = order.created_at.astimezone(_tz) if order.created_at else datetime.now(_tz)
return (
f"🆕 ЗАМОВЛЕННЯ {order.display_number()}\n\n"
f"🚚 ДОСТАВКА\n\n"
f"Ім'я: {order.customer_name}\n"
f"Телефон: {order.customer_phone}\n"
f"Адреса: {address}\n\n"
f"Замовлення:\n{_items_lines(order)}\n\n"
f"Сума товарів: {order.items_total} грн\n"
f"Доставка: {order.delivery_fee} грн\n"
f"Разом: {order.total} грн\n\n"
f"{_payment_lines(order)}\n\n"
f"Час замовлення: {created.strftime('%H:%M')}"
)
def format_pickup_order(order: Order) -> str:
created = order.created_at.astimezone(_tz) if order.created_at else datetime.now(_tz)
discount_line = (
f"Знижка самовивозу: {order.discount_amount} грн\n" if order.discount_amount else ""
)
return (
f"🆕 ЗАМОВЛЕННЯ {order.display_number()}\n\n"
f"🛍 САМОВИВІЗ\n\n"
f"Ім'я: {order.customer_name}\n"
f"Телефон: {order.customer_phone}\n"
f"Час прибуття: {order.pickup_time}\n\n"
f"Замовлення:\n{_items_lines(order)}\n\n"
f"Сума: {order.items_total} грн\n"
f"{discount_line}"
f"Разом: {order.total} грн\n\n"
f"{_payment_lines(order)}\n\n"
f"Час замовлення: {created.strftime('%H:%M')}"
)
def format_order(order: Order) -> str:
return format_delivery_order(order) if order.order_type == "delivery" else format_pickup_order(order)

View file

@ -0,0 +1,23 @@
"""Delivery fee and pickup discount calculations, driven by admin-editable Settings."""
from bober_bbq.models import Setting
def calc_delivery_fee(items_total: int) -> int:
if items_total <= 0:
return 0
threshold = Setting.get_int("free_delivery_threshold", 500)
if threshold and items_total >= threshold:
return 0
return Setting.get_int("delivery_fee", 60)
def calc_pickup_discount(items_total: int) -> int:
if items_total <= 0:
return 0
percent = Setting.get_int("pickup_discount_percent", 15)
return round(items_total * percent / 100)
def pickup_discount_percent() -> int:
return Setting.get_int("pickup_discount_percent", 15)

View file

@ -0,0 +1,69 @@
"""Validation of Telegram Mini App `initData`, per the official algorithm:
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
Every request the web app makes to our API must carry the raw `initData`
string (as sent by Telegram.WebApp.initData) in the `X-Telegram-Init-Data`
header. We verify its HMAC signature against the bot token before trusting
the embedded Telegram user id this is the "API protection" required by the
spec (section 18.4).
"""
import hashlib
import hmac
import json
import time
from urllib.parse import parse_qsl
from bober_bbq.config import config
MAX_AGE_SECONDS = 24 * 60 * 60 # reject initData older than 24h
def _build_secret_key(bot_token: str) -> bytes:
return hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
def validate_init_data(init_data: str, bot_token: str | None = None, max_age: int = MAX_AGE_SECONDS) -> dict | None:
"""Return the parsed initData fields (with `user` as a dict) if the signature
is valid and not expired, otherwise None."""
bot_token = bot_token or config.BOT_TOKEN
if not init_data or not bot_token:
return None
try:
pairs = parse_qsl(init_data, strict_parsing=True)
except ValueError:
return None
data = dict(pairs)
received_hash = data.pop("hash", None)
if not received_hash:
return None
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(data.items()))
secret_key = _build_secret_key(bot_token)
computed_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(computed_hash, received_hash):
return None
auth_date = data.get("auth_date")
if auth_date and max_age:
try:
if time.time() - int(auth_date) > max_age:
return None
except ValueError:
pass
if "user" in data:
try:
data["user"] = json.loads(data["user"])
except json.JSONDecodeError:
data["user"] = None
return data
def dev_bypass_user(telegram_id: int) -> dict:
"""Used only for local development without a real Telegram WebApp context."""
return {"user": {"id": telegram_id, "first_name": "Dev", "username": "dev"}}

View file

@ -0,0 +1,27 @@
"""Fire-and-forget Telegram Bot API calls made from synchronous (Flask)
request handlers e.g. the monobank webhook where spinning up the async
aiogram bot instance would be overkill."""
import logging
import requests
from bober_bbq.config import config
logger = logging.getLogger(__name__)
def send_message(chat_id: int | str, text: str) -> bool:
if not config.BOT_TOKEN:
logger.warning("BOT_TOKEN not set, cannot send Telegram message")
return False
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
try:
resp = requests.post(url, json={"chat_id": chat_id, "text": text}, timeout=10)
if resp.status_code != 200:
logger.error("sendMessage failed: %s %s", resp.status_code, resp.text)
return False
return True
except requests.RequestException:
logger.exception("sendMessage request failed")
return False

View file

@ -0,0 +1,51 @@
"""Working-hours gate — used to warn customers when the cafe is closed.
Menu browsing always stays available; only order placement is gated, and the
admin can toggle `allow_orders_outside_hours` to accept orders anyway
(fulfilled once the cafe reopens).
"""
from datetime import datetime, time
from zoneinfo import ZoneInfo
from bober_bbq.config import config
from bober_bbq.models import Setting
_tz = ZoneInfo(config.TIMEZONE)
def _parse_hhmm(value: str, fallback: time) -> time:
try:
h, m = value.split(":")
return time(int(h), int(m))
except (ValueError, AttributeError):
return fallback
def working_hours() -> tuple[str, str]:
return Setting.get("work_hours_from", "11:00"), Setting.get("work_hours_to", "21:00")
def is_open(now: datetime | None = None) -> bool:
now = (now or datetime.now(_tz)).astimezone(_tz)
start_raw, end_raw = working_hours()
start = _parse_hhmm(start_raw, time(11, 0))
end = _parse_hhmm(end_raw, time(21, 0))
current = now.time()
if start <= end:
return start <= current <= end
# overnight schedule, e.g. 20:00 - 02:00
return current >= start or current <= end
def orders_allowed() -> bool:
return is_open() or Setting.get_bool("allow_orders_outside_hours", False)
def closed_message() -> str:
start, end = working_hours()
return (
f"Наразі Bober BBQ не приймає замовлення.\n"
f"Графік роботи: щодня з {start} до {end}.\n"
f"Ви можете переглянути меню зараз, а оформити замовлення під час роботи закладу."
)

0
bot/__init__.py Normal file
View file

0
bot/handlers/__init__.py Normal file
View file

71
bot/handlers/cart.py Normal file
View file

@ -0,0 +1,71 @@
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from bot.handlers.delivery import start_delivery_flow
from bot.handlers.pickup import start_pickup_flow
from bot.keyboards import BTN_CART, cart_keyboard
from bot.services.cart_service import change_quantity, clear_cart, get_cart_items, remove_item, render_cart_text
router = Router(name="cart")
async def _render_cart(user_id: int):
items = get_cart_items(user_id)
return render_cart_text(items), cart_keyboard(items)
@router.message(F.text == BTN_CART)
async def show_cart(message: Message):
text, keyboard = await _render_cart(message.from_user.id)
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data == "noop")
async def noop(callback: CallbackQuery):
await callback.answer()
@router.callback_query(F.data.startswith("cart:inc:"))
async def cart_inc(callback: CallbackQuery):
product_id = int(callback.data.split(":")[2])
change_quantity(callback.from_user.id, product_id, +1)
await _refresh_cart_message(callback)
@router.callback_query(F.data.startswith("cart:dec:"))
async def cart_dec(callback: CallbackQuery):
product_id = int(callback.data.split(":")[2])
change_quantity(callback.from_user.id, product_id, -1)
await _refresh_cart_message(callback)
@router.callback_query(F.data.startswith("cart:del:"))
async def cart_del(callback: CallbackQuery):
product_id = int(callback.data.split(":")[2])
remove_item(callback.from_user.id, product_id)
await _refresh_cart_message(callback)
@router.callback_query(F.data == "cart:clear")
async def cart_clear(callback: CallbackQuery):
clear_cart(callback.from_user.id)
await _refresh_cart_message(callback)
async def _refresh_cart_message(callback: CallbackQuery):
text, keyboard = await _render_cart(callback.from_user.id)
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
@router.callback_query(F.data == "cart:checkout:delivery")
async def checkout_delivery(callback: CallbackQuery, state: FSMContext):
await callback.answer()
await start_delivery_flow(callback.bot, callback.from_user.id, callback.message.chat.id, state)
@router.callback_query(F.data == "cart:checkout:pickup")
async def checkout_pickup(callback: CallbackQuery, state: FSMContext):
await callback.answer()
await start_pickup_flow(callback.bot, callback.from_user.id, callback.message.chat.id, state)

35
bot/handlers/contacts.py Normal file
View file

@ -0,0 +1,35 @@
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from bober_bbq.models import Setting
from bot.keyboards import BTN_CONTACTS, main_menu_keyboard
router = Router(name="contacts")
def _contacts_text() -> str:
cafe_name = Setting.get("cafe_name", "Bober BBQ")
phone = Setting.get("phone")
address = Setting.get("address")
start, end = Setting.get("work_hours_from", "11:00"), Setting.get("work_hours_to", "21:00")
instagram = Setting.get("instagram_url")
maps_url = Setting.get("maps_url")
lines = [
f"{cafe_name}\n",
f"📞 Телефон: {phone}",
f"📍 Адреса: {address}",
f"🕐 Графік роботи: щодня з {start} до {end}",
]
if instagram:
lines.append(f"📷 Instagram: {instagram}")
if maps_url:
lines.append(f"🗺 Карта: {maps_url}")
return "\n".join(lines)
@router.message(Command("contacts"))
@router.message(lambda m: m.text == BTN_CONTACTS)
async def show_contacts(message: Message):
await message.answer(_contacts_text(), reply_markup=main_menu_keyboard())

185
bot/handlers/delivery.py Normal file
View file

@ -0,0 +1,185 @@
from aiogram import Bot, F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from bot.keyboards import (
BTN_DELIVERY,
cash_change_keyboard,
contact_request_keyboard,
main_menu_keyboard,
payment_method_keyboard,
remove_keyboard,
skip_inline_keyboard,
)
from bot.services import notify, order_service
from bot.services.cart_service import get_cart_items
from bot.services.payment_flow import offer_card_payment
from bot.states import DeliveryForm
from bober_bbq.utils.workhours import closed_message, orders_allowed
router = Router(name="delivery")
async def start_delivery_flow(bot: Bot, user_id: int, chat_id: int, state: FSMContext):
items = get_cart_items(user_id)
if not items:
await bot.send_message(chat_id, "Ваш кошик порожній. Перейдіть до меню, щоб додати страви.")
return
if not orders_allowed():
await bot.send_message(chat_id, closed_message())
return
items_total = sum(i.line_total for i in items)
fee, total = order_service.quote_delivery(items_total)
lines = ["Ваше замовлення:\n"]
for item in items:
lines.append(f"{item.product.name} × {item.quantity}{item.line_total} грн")
lines.append(f"\nСума товарів: {items_total} грн")
lines.append(f"Доставка: {fee} грн")
lines.append(f"Разом: {total} грн")
await bot.send_message(chat_id, "\n".join(lines))
await state.set_state(DeliveryForm.address_city)
await bot.send_message(chat_id, "Вкажіть місто доставки:")
@router.message(F.text == BTN_DELIVERY)
async def on_delivery_button(message: Message, state: FSMContext):
await start_delivery_flow(message.bot, message.from_user.id, message.chat.id, state)
@router.message(DeliveryForm.address_city)
async def step_city(message: Message, state: FSMContext):
await state.update_data(address_city=message.text.strip())
await state.set_state(DeliveryForm.address_street)
await message.answer("Вкажіть вулицю:")
@router.message(DeliveryForm.address_street)
async def step_street(message: Message, state: FSMContext):
await state.update_data(address_street=message.text.strip())
await state.set_state(DeliveryForm.address_house)
await message.answer("Вкажіть номер будинку:")
@router.message(DeliveryForm.address_house)
async def step_house(message: Message, state: FSMContext):
await state.update_data(address_house=message.text.strip())
await state.set_state(DeliveryForm.address_apartment)
await message.answer("Вкажіть квартиру / офіс (або натисніть «Пропустити»):", reply_markup=skip_inline_keyboard("skip:apartment"))
@router.callback_query(F.data == "skip:apartment", DeliveryForm.address_apartment)
async def step_apartment_skip(callback: CallbackQuery, state: FSMContext):
await state.update_data(address_apartment=None)
await state.set_state(DeliveryForm.address_comment)
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer("Коментар до адреси (під'їзд, поверх тощо) — або «Пропустити»:", reply_markup=skip_inline_keyboard("skip:comment"))
await callback.answer()
@router.message(DeliveryForm.address_apartment)
async def step_apartment(message: Message, state: FSMContext):
await state.update_data(address_apartment=message.text.strip())
await state.set_state(DeliveryForm.address_comment)
await message.answer("Коментар до адреси (під'їзд, поверх тощо) — або «Пропустити»:", reply_markup=skip_inline_keyboard("skip:comment"))
@router.callback_query(F.data == "skip:comment", DeliveryForm.address_comment)
async def step_comment_skip(callback: CallbackQuery, state: FSMContext):
await state.update_data(address_comment=None)
await state.set_state(DeliveryForm.name)
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer("Вкажіть ваше ім'я:")
await callback.answer()
@router.message(DeliveryForm.address_comment)
async def step_comment(message: Message, state: FSMContext):
await state.update_data(address_comment=message.text.strip())
await state.set_state(DeliveryForm.name)
await message.answer("Вкажіть ваше ім'я:")
@router.message(DeliveryForm.name)
async def step_name(message: Message, state: FSMContext):
await state.update_data(name=message.text.strip())
await state.set_state(DeliveryForm.phone)
await message.answer(
"Вкажіть номер телефону або поділіться контактом:", reply_markup=contact_request_keyboard()
)
@router.message(DeliveryForm.phone, F.contact)
async def step_phone_contact(message: Message, state: FSMContext):
await state.update_data(phone=message.contact.phone_number)
await _ask_payment_method(message, state)
@router.message(DeliveryForm.phone)
async def step_phone_text(message: Message, state: FSMContext):
await state.update_data(phone=message.text.strip())
await _ask_payment_method(message, state)
async def _ask_payment_method(message: Message, state: FSMContext):
await state.set_state(DeliveryForm.payment_method)
await message.answer("Спосіб оплати:", reply_markup=remove_keyboard())
await message.answer("Оберіть спосіб оплати:", reply_markup=payment_method_keyboard())
@router.callback_query(F.data == "pay_method:cash", DeliveryForm.payment_method)
async def choose_cash(callback: CallbackQuery, state: FSMContext):
await state.set_state(DeliveryForm.cash_change)
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer(
"Вкажіть номінал купюри, з якої потрібна здача (наприклад 1000), або натисніть «Без решти»:",
reply_markup=cash_change_keyboard(),
)
await callback.answer()
@router.callback_query(F.data == "cash_change:none", DeliveryForm.cash_change)
async def cash_change_none(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_reply_markup(reply_markup=None)
await _finalize_order(callback.bot, callback.from_user.id, callback.message.chat.id, state, "cash", None)
await callback.answer()
@router.message(DeliveryForm.cash_change)
async def cash_change_value(message: Message, state: FSMContext):
try:
value = int("".join(ch for ch in message.text if ch.isdigit()))
except ValueError:
await message.answer("Будь ласка, вкажіть суму цифрами, наприклад 1000.")
return
await _finalize_order(message.bot, message.from_user.id, message.chat.id, state, "cash", value)
@router.callback_query(F.data == "pay_method:card", DeliveryForm.payment_method)
async def choose_card(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_reply_markup(reply_markup=None)
await _finalize_order(callback.bot, callback.from_user.id, callback.message.chat.id, state, "card", None)
await callback.answer()
async def _finalize_order(bot: Bot, user_id: int, chat_id: int, state: FSMContext, payment_method: str, cash_change_for: int | None):
data = await state.get_data()
data["payment_method"] = payment_method
data["cash_change_for"] = cash_change_for
order = order_service.create_delivery_order(user_id, data)
await state.clear()
await notify.send_order_to_manager(bot, order)
await bot.send_message(
chat_id,
"Дякуємо за замовлення! Ми вже приступаємо до його виконання. "
"За потреби менеджер зв'яжеться з вами.",
reply_markup=main_menu_keyboard(),
)
if payment_method == "card":
await offer_card_payment(bot, chat_id, order)

19
bot/handlers/menu.py Normal file
View file

@ -0,0 +1,19 @@
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from bot.keyboards import BTN_MENU, menu_inline_keyboard
router = Router(name="menu")
@router.message(Command("menu"))
@router.message(lambda m: m.text == BTN_MENU)
async def open_menu(message: Message):
keyboard = menu_inline_keyboard()
if keyboard is None:
await message.answer(
"Меню поки недоступне: адміністратору потрібно вказати WEBAPP_URL у налаштуваннях бота."
)
return
await message.answer("Відкрийте меню, щоб обрати страви 👇", reply_markup=keyboard)

63
bot/handlers/payment.py Normal file
View file

@ -0,0 +1,63 @@
from datetime import datetime, timezone
from aiogram import F, Router
from aiogram.types import CallbackQuery
from bober_bbq.extensions import db
from bober_bbq.models import Order, Setting
from bober_bbq.payments.monobank import FAILED_STATUSES, PAID_STATUSES, MonobankError, get_invoice_status
router = Router(name="payment")
@router.callback_query(F.data.startswith("pay_check:"))
async def pay_check(callback: CallbackQuery):
order_id = int(callback.data.split(":", 1)[1])
order = db.session.get(Order, order_id)
if not order:
await callback.answer("Замовлення не знайдено.", show_alert=True)
return
if order.payment_status == "paid":
await callback.answer("Оплату вже підтверджено ✅", show_alert=True)
return
if not order.monobank_invoice_id:
await callback.answer("Платіж ще не створено.", show_alert=True)
return
try:
status_data = get_invoice_status(order.monobank_invoice_id)
except MonobankError:
await callback.answer("Не вдалося перевірити статус оплати, спробуйте ще раз трохи пізніше.", show_alert=True)
return
status = status_data.get("status")
if status in PAID_STATUSES:
order.payment_status = "paid"
order.paid_at = datetime.now(timezone.utc)
db.session.commit()
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer(f"✅ Оплата замовлення {order.display_number()} підтверджена. Дякуємо!")
staff_chat = Setting.get("delivery_chat_id") if order.order_type == "delivery" else Setting.get("pickup_chat_id")
if staff_chat:
await callback.bot.send_message(staff_chat, f"💳 Замовлення {order.display_number()} оплачено онлайн (✅).")
await callback.answer()
elif status in FAILED_STATUSES:
await callback.answer("Оплата не пройшла. Спробуйте ще раз або оплатіть при отриманні.", show_alert=True)
else:
await callback.answer("Оплату ще не отримано. Спробуйте перевірити ще раз за хвилину.", show_alert=True)
@router.callback_query(F.data.startswith("pay_cancel:"))
async def pay_cancel(callback: CallbackQuery):
order_id = int(callback.data.split(":", 1)[1])
order = db.session.get(Order, order_id)
await callback.message.edit_reply_markup(reply_markup=None)
if order:
await callback.message.answer(
f"Оплату скасовано. Замовлення {order.display_number()} залишається неоплаченим — "
"менеджер зв'яжеться з вами для узгодження способу оплати."
)
await callback.answer()

174
bot/handlers/pickup.py Normal file
View file

@ -0,0 +1,174 @@
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from aiogram import Bot, F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from bober_bbq.config import config
from bot.keyboards import (
BTN_PICKUP,
contact_request_keyboard,
main_menu_keyboard,
pickup_payment_choice_keyboard,
pickup_time_keyboard,
remove_keyboard,
)
from bot.services import notify, order_service
from bot.services.cart_service import get_cart_items
from bot.services.payment_flow import offer_card_payment
from bot.states import PickupForm
from bober_bbq.utils.workhours import closed_message, orders_allowed, working_hours
router = Router(name="pickup")
_tz = ZoneInfo(config.TIMEZONE)
def _next_slots(count: int = 4, step_minutes: int = 30) -> list[str]:
now = datetime.now(_tz)
minute = (now.minute // step_minutes + 1) * step_minutes
start = now.replace(minute=0, second=0, microsecond=0) + timedelta(minutes=minute)
_, end_raw = working_hours()
try:
end_h, end_m = (int(x) for x in end_raw.split(":"))
closing = now.replace(hour=end_h, minute=end_m, second=0, microsecond=0)
except ValueError:
closing = None
slots = []
t = start
while len(slots) < count:
if closing is None or t <= closing:
slots.append(t.strftime("%H:%M"))
t += timedelta(minutes=step_minutes)
if closing and t > closing + timedelta(hours=1):
break
return slots or ["18:00", "18:30", "19:00", "19:30"]
async def start_pickup_flow(bot: Bot, user_id: int, chat_id: int, state: FSMContext):
items = get_cart_items(user_id)
if not items:
await bot.send_message(chat_id, "Ваш кошик порожній. Перейдіть до меню, щоб додати страви.")
return
if not orders_allowed():
await bot.send_message(chat_id, closed_message())
return
items_total = sum(i.line_total for i in items)
discount, total = order_service.quote_pickup(items_total)
lines = ["Ваше замовлення (самовивіз):\n"]
for item in items:
lines.append(f"{item.product.name} × {item.quantity}{item.line_total} грн")
lines.append(f"\nСума замовлення: {items_total} грн")
lines.append(f"Знижка за самовивіз: {discount} грн")
lines.append(f"До сплати: {total} грн")
await bot.send_message(chat_id, "\n".join(lines))
await state.set_state(PickupForm.name)
await bot.send_message(chat_id, "Вкажіть ваше ім'я:")
@router.message(F.text == BTN_PICKUP)
async def on_pickup_button(message: Message, state: FSMContext):
await start_pickup_flow(message.bot, message.from_user.id, message.chat.id, state)
@router.message(PickupForm.name)
async def step_name(message: Message, state: FSMContext):
await state.update_data(name=message.text.strip())
await state.set_state(PickupForm.phone)
await message.answer(
"Вкажіть номер телефону або поділіться контактом:", reply_markup=contact_request_keyboard()
)
@router.message(PickupForm.phone, F.contact)
async def step_phone_contact(message: Message, state: FSMContext):
await state.update_data(phone=message.contact.phone_number)
await _ask_pickup_time(message, state)
@router.message(PickupForm.phone)
async def step_phone_text(message: Message, state: FSMContext):
await state.update_data(phone=message.text.strip())
await _ask_pickup_time(message, state)
async def _ask_pickup_time(message: Message, state: FSMContext):
await state.set_state(PickupForm.pickup_time)
await message.answer("Через який час / о котрій годині ви зможете забрати замовлення?", reply_markup=remove_keyboard())
await message.answer("Оберіть час:", reply_markup=pickup_time_keyboard(_next_slots()))
@router.callback_query(F.data == "pickup_time:other", PickupForm.pickup_time)
async def pickup_time_other(callback: CallbackQuery, state: FSMContext):
await state.set_state(PickupForm.pickup_time_custom)
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer("Вкажіть бажаний час прибуття (наприклад 20:15):")
await callback.answer()
@router.callback_query(F.data.startswith("pickup_time:"), PickupForm.pickup_time)
async def pickup_time_choice(callback: CallbackQuery, state: FSMContext):
time_value = callback.data.split(":", 1)[1]
await state.update_data(pickup_time=time_value)
await callback.message.edit_reply_markup(reply_markup=None)
await callback.answer()
await _ask_payment(callback.bot, callback.from_user.id, callback.message.chat.id, state)
@router.message(PickupForm.pickup_time_custom)
async def pickup_time_custom(message: Message, state: FSMContext):
await state.update_data(pickup_time=message.text.strip())
await _ask_payment(message.bot, message.from_user.id, message.chat.id, state)
async def _ask_payment(bot: Bot, user_id: int, chat_id: int, state: FSMContext):
await state.set_state(PickupForm.payment_choice)
items = get_cart_items(user_id)
items_total = sum(i.line_total for i in items)
_, total = order_service.quote_pickup(items_total)
await bot.send_message(
chat_id,
f"До сплати: {total} грн\n\nОплатіть карткою онлайн або оберіть «Продовжити без оплати», "
"щоб розрахуватись при отриманні замовлення.",
reply_markup=pickup_payment_choice_keyboard(),
)
@router.callback_query(F.data == "pay_choice:cash", PickupForm.payment_choice)
async def pay_choice_cash(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_reply_markup(reply_markup=None)
await _finalize_pickup(callback.bot, callback.from_user.id, callback.message.chat.id, state, "cash")
await callback.answer()
@router.callback_query(F.data == "pay_choice:card", PickupForm.payment_choice)
async def pay_choice_card(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_reply_markup(reply_markup=None)
await _finalize_pickup(callback.bot, callback.from_user.id, callback.message.chat.id, state, "card")
await callback.answer()
async def _finalize_pickup(bot: Bot, user_id: int, chat_id: int, state: FSMContext, payment_method: str):
data = await state.get_data()
data["payment_method"] = payment_method
order = order_service.create_pickup_order(user_id, data)
await state.clear()
await notify.send_order_to_manager(bot, order)
await bot.send_message(
chat_id,
"Дякуємо за замовлення! Ми вже приступаємо до його виконання. "
"За потреби менеджер зв'яжеться з вами.",
reply_markup=main_menu_keyboard(),
)
if payment_method == "card":
await offer_card_payment(bot, chat_id, order)

25
bot/handlers/start.py Normal file
View file

@ -0,0 +1,25 @@
from aiogram import Router
from aiogram.filters import CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
from bober_bbq.models import Setting
from bot.keyboards import main_menu_keyboard
from bot.services.cart_service import get_or_create_user
router = Router(name="start")
@router.message(CommandStart())
async def cmd_start(message: Message, state: FSMContext):
await state.clear()
get_or_create_user(message.from_user.id, message.from_user.username, message.from_user.first_name)
cafe_name = Setting.get("cafe_name", "Bober BBQ")
text = (
f"Вітаємо в {cafe_name}! 🦫🔥\n\n"
"Тут ви можете переглянути меню, зробити замовлення на доставку "
"або самовивіз просто в Telegram.\n\n"
"Оберіть дію в меню нижче 👇"
)
await message.answer(text, reply_markup=main_menu_keyboard())

View file

@ -0,0 +1,37 @@
"""Handles data sent from the Mini App via Telegram.WebApp.sendData(), e.g.
when the customer taps "Оформити доставку" / "Оформити самовивіз" inside the
web app cart screen. The web app itself only manages menu browsing and the
cart (via the REST API); checkout continues here as a normal bot FSM flow.
"""
import json
import logging
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
from bot.handlers.delivery import start_delivery_flow
from bot.handlers.pickup import start_pickup_flow
router = Router(name="webapp_data")
logger = logging.getLogger(__name__)
@router.message(F.web_app_data)
async def on_webapp_data(message: Message, state: FSMContext):
try:
payload = json.loads(message.web_app_data.data)
except (json.JSONDecodeError, AttributeError):
logger.warning("Invalid web_app_data payload: %r", message.web_app_data.data)
return
action = payload.get("action")
if action == "checkout_delivery":
await start_delivery_flow(message.bot, message.from_user.id, message.chat.id, state)
elif action == "checkout_pickup":
await start_pickup_flow(message.bot, message.from_user.id, message.chat.id, state)
elif action == "open_cart":
from bot.handlers.cart import show_cart
await show_cart(message)

124
bot/keyboards.py Normal file
View file

@ -0,0 +1,124 @@
from aiogram.types import (
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
WebAppInfo,
)
from bober_bbq.config import config
BTN_MENU = "🍽 Меню"
BTN_DELIVERY = "🚚 Доставка"
BTN_PICKUP = "🛍 Самовивіз"
BTN_CART = "🛒 Кошик"
BTN_CONTACTS = "📞 Контакти"
SKIP = "Пропустити"
NO_CHANGE = "Без решти"
OTHER_TIME = "Інший час"
PAY_CARD = "💳 Оплатити карткою"
CONTINUE_NO_PAY = "Продовжити без оплати"
CASH = "💵 Готівка"
CARD = "💳 Картка"
SHARE_CONTACT = "📱 Поділитися контактом"
def main_menu_keyboard() -> ReplyKeyboardMarkup:
if config.WEBAPP_URL:
menu_button = KeyboardButton(text=BTN_MENU, web_app=WebAppInfo(url=config.WEBAPP_URL))
else:
menu_button = KeyboardButton(text=BTN_MENU)
return ReplyKeyboardMarkup(
keyboard=[
[menu_button],
[KeyboardButton(text=BTN_DELIVERY), KeyboardButton(text=BTN_PICKUP)],
[KeyboardButton(text=BTN_CART), KeyboardButton(text=BTN_CONTACTS)],
],
resize_keyboard=True,
)
def menu_inline_keyboard() -> InlineKeyboardMarkup | None:
if not config.WEBAPP_URL:
return None
return InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=BTN_MENU, web_app=WebAppInfo(url=config.WEBAPP_URL))]]
)
def contact_request_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[[KeyboardButton(text=SHARE_CONTACT, request_contact=True)]],
resize_keyboard=True,
one_time_keyboard=True,
)
def remove_keyboard() -> ReplyKeyboardRemove:
return ReplyKeyboardRemove()
def skip_inline_keyboard(callback_data: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=SKIP, callback_data=callback_data)]])
def payment_method_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=CASH, callback_data="pay_method:cash")],
[InlineKeyboardButton(text=CARD, callback_data="pay_method:card")],
]
)
def cash_change_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=NO_CHANGE, callback_data="cash_change:none")]]
)
def pickup_time_keyboard(slots: list[str]) -> InlineKeyboardMarkup:
rows = [[InlineKeyboardButton(text=s, callback_data=f"pickup_time:{s}") for s in slots[i : i + 2]] for i in range(0, len(slots), 2)]
rows.append([InlineKeyboardButton(text=OTHER_TIME, callback_data="pickup_time:other")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def pickup_payment_choice_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=PAY_CARD, callback_data="pay_choice:card")],
[InlineKeyboardButton(text=CONTINUE_NO_PAY, callback_data="pay_choice:cash")],
]
)
def card_payment_keyboard(pay_url: str, order_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=f"{PAY_CARD}", url=pay_url)],
[InlineKeyboardButton(text="🔄 Перевірити оплату", callback_data=f"pay_check:{order_id}")],
[InlineKeyboardButton(text="❌ Скасувати оплату", callback_data=f"pay_cancel:{order_id}")],
]
)
def cart_keyboard(items: list, order_type_buttons: bool = True) -> InlineKeyboardMarkup | None:
if not items:
return None
rows = []
for item in items:
rows.append(
[
InlineKeyboardButton(text="", callback_data=f"cart:dec:{item.product_id}"),
InlineKeyboardButton(text=f"{item.product.name} × {item.quantity}", callback_data="noop"),
InlineKeyboardButton(text="+", callback_data=f"cart:inc:{item.product_id}"),
InlineKeyboardButton(text="🗑", callback_data=f"cart:del:{item.product_id}"),
]
)
rows.append([InlineKeyboardButton(text="🧹 Очистити кошик", callback_data="cart:clear")])
if order_type_buttons:
rows.append([InlineKeyboardButton(text=BTN_DELIVERY, callback_data="cart:checkout:delivery")])
rows.append([InlineKeyboardButton(text=BTN_PICKUP, callback_data="cart:checkout:pickup")])
return InlineKeyboardMarkup(inline_keyboard=rows)

49
bot/main.py Normal file
View file

@ -0,0 +1,49 @@
import asyncio
import logging
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
from bober_bbq.app import create_app
from bober_bbq.config import config
from bober_bbq.extensions import db
from bober_bbq.seed import seed
from bot.handlers import cart, contacts, delivery, menu, payment, pickup, start, webapp_data
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger(__name__)
async def main():
if not config.BOT_TOKEN:
raise RuntimeError("BOT_TOKEN is not set — copy .env.example to .env and fill it in")
# Bot and Flask backend share the same SQLAlchemy models/DB. Pushing a
# single long-lived app context lets the bot's handlers use `db.session`
# exactly like the web app does, without spinning up a second web server.
flask_app = create_app()
flask_app.app_context().push()
db.create_all()
seed()
bot = Bot(token=config.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher(storage=MemoryStorage())
dp.include_router(start.router)
dp.include_router(webapp_data.router)
dp.include_router(menu.router)
dp.include_router(cart.router)
dp.include_router(delivery.router)
dp.include_router(pickup.router)
dp.include_router(payment.router)
dp.include_router(contacts.router)
await bot.delete_webhook(drop_pending_updates=True)
logger.info("Bober BBQ bot starting (long polling)...")
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())

0
bot/services/__init__.py Normal file
View file

View file

@ -0,0 +1,54 @@
from bober_bbq.extensions import db
from bober_bbq.models import CartItem, Product, TelegramUser
def get_or_create_user(telegram_id: int, username: str | None, first_name: str | None) -> TelegramUser:
user = db.session.get(TelegramUser, telegram_id)
if user is None:
user = TelegramUser(telegram_id=telegram_id, username=username, first_name=first_name)
db.session.add(user)
else:
user.username = username
user.first_name = first_name
db.session.commit()
return user
def get_cart_items(user_id: int) -> list[CartItem]:
return CartItem.query.filter_by(user_id=user_id).join(Product).order_by(CartItem.created_at).all()
def cart_total(user_id: int) -> int:
return sum(item.line_total for item in get_cart_items(user_id))
def change_quantity(user_id: int, product_id: int, delta: int) -> None:
item = CartItem.query.filter_by(user_id=user_id, product_id=product_id).first()
if not item:
return
item.quantity += delta
if item.quantity <= 0:
db.session.delete(item)
db.session.commit()
def remove_item(user_id: int, product_id: int) -> None:
item = CartItem.query.filter_by(user_id=user_id, product_id=product_id).first()
if item:
db.session.delete(item)
db.session.commit()
def clear_cart(user_id: int) -> None:
CartItem.query.filter_by(user_id=user_id).delete()
db.session.commit()
def render_cart_text(items: list[CartItem]) -> str:
if not items:
return "Ваш кошик порожній. Перейдіть до меню, щоб додати страви."
lines = ["🛒 Ваш кошик:\n"]
for item in items:
lines.append(f"{item.product.name}{item.quantity} шт. × {item.product.price} грн = {item.line_total} грн")
lines.append(f"\nВсього: {sum(i.line_total for i in items)} грн")
return "\n".join(lines)

34
bot/services/notify.py Normal file
View file

@ -0,0 +1,34 @@
import logging
from aiogram import Bot
from bober_bbq.config import config
from bober_bbq.models import Order, Setting
from bober_bbq.utils.formatting import format_order
logger = logging.getLogger(__name__)
async def send_order_to_manager(bot: Bot, order: Order) -> None:
text = format_order(order)
if order.order_type == "delivery":
chat_id = Setting.get("delivery_chat_id") or None
else:
chat_id = Setting.get("pickup_chat_id") or None
targets: list[int | str] = []
if chat_id:
targets.append(chat_id)
elif config.OWNER_IDS:
targets.extend(config.OWNER_IDS)
if not targets:
logger.warning("No manager chat configured for order %s — nobody was notified", order.display_number())
return
for target in targets:
try:
await bot.send_message(target, text)
except Exception:
logger.exception("Failed to notify chat %s about order %s", target, order.display_number())

View file

@ -0,0 +1,93 @@
from bober_bbq.extensions import db
from bober_bbq.models import Order, OrderItem, Setting
from bober_bbq.utils.pricing import calc_delivery_fee, calc_pickup_discount
from bot.services.cart_service import clear_cart, get_cart_items
def _next_order_number(order_id: int) -> int:
return Setting.get_int("order_number_start", 1000) + order_id
def _add_items(order: Order, cart_items) -> int:
items_total = 0
for cart_item in cart_items:
db.session.add(
OrderItem(
order=order,
product_id=cart_item.product_id,
product_name=cart_item.product.name,
product_price=cart_item.product.price,
quantity=cart_item.quantity,
)
)
items_total += cart_item.line_total
return items_total
def create_delivery_order(user_id: int, data: dict) -> Order:
cart_items = get_cart_items(user_id)
order = Order(
user_id=user_id,
order_type="delivery",
customer_name=data["name"],
customer_phone=data["phone"],
address_city=data.get("address_city"),
address_street=data.get("address_street"),
address_house=data.get("address_house"),
address_apartment=data.get("address_apartment"),
address_comment=data.get("address_comment"),
payment_method=data["payment_method"],
cash_change_for=data.get("cash_change_for"),
)
db.session.add(order)
db.session.flush()
items_total = _add_items(order, cart_items)
delivery_fee = calc_delivery_fee(items_total)
order.items_total = items_total
order.delivery_fee = delivery_fee
order.discount_amount = 0
order.total = items_total + delivery_fee
order.number = _next_order_number(order.id)
db.session.commit()
clear_cart(user_id)
return order
def create_pickup_order(user_id: int, data: dict) -> Order:
cart_items = get_cart_items(user_id)
order = Order(
user_id=user_id,
order_type="pickup",
customer_name=data["name"],
customer_phone=data["phone"],
pickup_time=data["pickup_time"],
payment_method=data["payment_method"],
)
db.session.add(order)
db.session.flush()
items_total = _add_items(order, cart_items)
discount = calc_pickup_discount(items_total)
order.items_total = items_total
order.delivery_fee = 0
order.discount_amount = discount
order.total = items_total - discount
order.number = _next_order_number(order.id)
db.session.commit()
clear_cart(user_id)
return order
def quote_delivery(items_total: int) -> tuple[int, int]:
fee = calc_delivery_fee(items_total)
return fee, items_total + fee
def quote_pickup(items_total: int) -> tuple[int, int]:
discount = calc_pickup_discount(items_total)
return discount, items_total - discount

View file

@ -0,0 +1,33 @@
from aiogram import Bot
from bober_bbq.extensions import db
from bober_bbq.models import Order
from bober_bbq.payments.monobank import MonobankError, MonobankNotConfigured, create_invoice
from bot.keyboards import card_payment_keyboard
async def offer_card_payment(bot: Bot, chat_id: int, order: Order) -> None:
try:
invoice = create_invoice(order)
except MonobankNotConfigured:
await bot.send_message(
chat_id,
"Оплата карткою онлайн тимчасово недоступна (не налаштовано monobank). "
"Менеджер зв'яжеться з вами для узгодження оплати.",
)
return
except MonobankError:
await bot.send_message(
chat_id, "Не вдалося створити платіж. Менеджер зв'яжеться з вами для узгодження оплати."
)
return
order.monobank_invoice_id = invoice.get("invoiceId")
order.monobank_page_url = invoice.get("pageUrl")
db.session.commit()
await bot.send_message(
chat_id,
f"Сума до сплати: {order.total} грн\nНатисніть кнопку нижче, щоб перейти до оплати через monobank.",
reply_markup=card_payment_keyboard(order.monobank_page_url, order.id),
)

21
bot/states.py Normal file
View file

@ -0,0 +1,21 @@
from aiogram.fsm.state import State, StatesGroup
class DeliveryForm(StatesGroup):
address_city = State()
address_street = State()
address_house = State()
address_apartment = State()
address_comment = State()
name = State()
phone = State()
payment_method = State()
cash_change = State()
class PickupForm(StatesGroup):
name = State()
phone = State()
pickup_time = State()
pickup_time_custom = State()
payment_choice = State()

134
docs/DEPLOYMENT.md Normal file
View file

@ -0,0 +1,134 @@
# Розгортання на VPS
Мінімальна робоча схема: один VPS (Ubuntu), Nginx як reverse-proxy й
термінатор TLS, два systemd-сервіси (backend + bot), SQLite (або Postgres,
якщо очікується більше навантаження).
## 1. Підготовка сервера
```bash
sudo apt update && sudo apt install -y python3.11 python3.11-venv nginx certbot python3-certbot-nginx nodejs npm
```
## 2. Код і залежності
```bash
git clone <репозиторій> /opt/bober-bbq
cd /opt/bober-bbq
python3.11 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cd webapp && npm install && npm run build && cd ..
```
## 3. `.env`
Скопіюйте `.env.example``.env`, заповніть:
- `BOT_TOKEN` — від [@BotFather](https://t.me/BotFather)
- `WEBAPP_URL=https://<ваш-домен>/webapp/`
- `BACKEND_PUBLIC_URL=https://<ваш-домен>`
- `MONOBANK_WEBHOOK_URL=https://<ваш-домен>/api/payments/monobank/webhook`
- `MONOBANK_TOKEN` — токен еквайрингу від замовника (розділ 22.8 ТЗ)
- `DATABASE_URL` — для Postgres: `postgresql+psycopg2://user:pass@localhost/bober_bbq`
- `SECRET_KEY`, `FLASK_ADMIN_USERNAME`, `FLASK_ADMIN_PASSWORD` — змінити на бойові значення
## 4. Nginx + TLS
```nginx
server {
listen 80;
server_name your-domain.example;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
```bash
sudo certbot --nginx -d your-domain.example
```
Telegram Mini App і monobank webhook **вимагають дійсний HTTPS-сертифікат**
— без нього WebApp-кнопка не відкриється, а вебхук оплати не спрацює.
## 5. systemd-юніти
`/etc/systemd/system/bober-bbq-web.service`:
```ini
[Unit]
Description=Bober BBQ backend (API + admin)
After=network.target
[Service]
WorkingDirectory=/opt/bober-bbq
ExecStart=/opt/bober-bbq/.venv/bin/python run_web.py
Restart=on-failure
EnvironmentFile=/opt/bober-bbq/.env
[Install]
WantedBy=multi-user.target
```
`/etc/systemd/system/bober-bbq-bot.service`:
```ini
[Unit]
Description=Bober BBQ Telegram bot
After=network.target bober-bbq-web.service
[Service]
WorkingDirectory=/opt/bober-bbq
ExecStart=/opt/bober-bbq/.venv/bin/python run_bot.py
Restart=on-failure
EnvironmentFile=/opt/bober-bbq/.env
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now bober-bbq-web bober-bbq-bot
```
## 6. Telegram-налаштування
1. У [@BotFather](https://t.me/BotFather): `/setmenubutton` → вкажіть
`WEBAPP_URL` як кнопку меню (додатково до reply-кнопки «🍽 Меню» в самому боті).
2. Додайте бота адміністратором у групи/канали для замовлень «Доставка» і
«Самовивіз», дізнайтесь їхні `chat_id` (переслати повідомлення з чату
боту [@userinfobot](https://t.me/userinfobot)) і впишіть в
Адмінка → Налаштування.
3. У [my.monobank.ua](https://my.monobank.ua) / кабінеті еквайрингу ФОП
переконайтесь, що вебхук `MONOBANK_WEBHOOK_URL` доступний ззовні (перевірте
`curl -X POST https://your-domain.example/api/payments/monobank/webhook`).
## 7. Резервне копіювання
SQLite: досить копіювати файл `instance/bober_bbq.db` за розкладом (cron):
```bash
0 3 * * * cp /opt/bober-bbq/instance/bober_bbq.db /opt/backups/bober_bbq-$(date +\%F).db
```
Postgres: стандартний `pg_dump` за розкладом. Не забудьте також бекапити
`bober_bbq/static/uploads/` (фото товарів) — БД зберігає лише посилання
на файли.
## 8. Оновлення коду
```bash
cd /opt/bober-bbq
git pull
source .venv/bin/activate
pip install -r requirements.txt
cd webapp && npm install && npm run build && cd ..
sudo systemctl restart bober-bbq-web bober-bbq-bot
```

30
docs/OPEN_QUESTIONS.md Normal file
View file

@ -0,0 +1,30 @@
# Що потрібно узгодити із замовником (розділ 22 ТЗ)
Код повністю робочий на демо-даних, але для запуску в продакшн потрібні
реальні дані замовника. Все нижче редагується або через `.env`, або через
адмінку (`/admin/settings`), або через сідинг реального меню — коду
чіпати не треба.
| # | Пункт ТЗ | Де налаштовується | Статус |
|---|---|---|---|
| 1 | Вартість доставки: фіксована / по зонах / від суми | `Адмінка → Налаштування`: `delivery_fee`, `free_delivery_threshold` | Реалізовано найпростіший варіант — **фіксована сума + безкоштовно від порогу**. Логіка по зонах (наприклад, за районом) потребує окремого узгодження формату адрес і не закладена. |
| 2 | Місто та зона доставки | Наразі бот приймає будь-яке місто текстом, без валідації/обмеження зони | Потребує рішення замовника |
| 3 | Адреса закладу для самовивозу | `Адмінка → Налаштування → address` | Заповнити |
| 4 | Телефон Bober BBQ | `Адмінка → Налаштування → phone` | Заповнити |
| 5 | Telegram-акаунт/група менеджера (fallback) | `.env → OWNER_IDS` | Заповнити |
| 6 | Окремий чат для доставки | `Адмінка → Налаштування → Chat ID — Доставка` | Заповнити (додати бота адміном у чат) |
| 7 | Окремий чат для самовивозу | `Адмінка → Налаштування → Chat ID — Самовивіз` | Заповнити (додати бота адміном у чат) |
| 8 | Реквізити ФОП / дані monobank | `.env → MONOBANK_TOKEN` | **Без цього оплата карткою недоступна** — бот коректно переключається на «оплата при отриманні» і просить менеджера зв'язатися з клієнтом, нічого не падає |
| 9 | Фінальний перелік категорій меню | `Адмінка → Категорії` або `bober_bbq/seed.py` | Зараз — демо-категорії (Шашлик, BBQ, Піца, Бургери, Гарніри, Закуски, Соуси, Напої) |
| 10 | Повний перелік товарів, цін, описів, фото | `Адмінка → Товари` | Зараз — по 1-2 демо-товари на категорію |
| 11 | Прийом замовлень поза графіком з виконанням у робочий час | `Адмінка → Налаштування → allow_orders_outside_hours` | Реалізовано як перемикач: вимкнено за замовчуванням (бот відмовляє поза графіком і пропонує лише перегляд меню) |
| 12 | Додаткові поля до замовлення (напр. коментар клієнта) | — | Коментар до **адреси** доставки вже є (розділ 5, крок 2). Окремого поля "коментар до всього замовлення" немає — додати за потреби |
## Додатково варто уточнити
- **Часовий пояс**: за замовчуванням `Europe/Kyiv` (`.env → TIMEZONE`).
- **Формат номера телефону**: наразі приймається як є (текст або контакт
Telegram), без валідації на `+380...`. Додати regex-перевірку, якщо
потрібно жорсткіше.
- **Мова**: інтерфейс лише українською, як і вимагає ТЗ — мультимовність не
закладена.

9
requirements.txt Normal file
View file

@ -0,0 +1,9 @@
aiogram>=3.22,<4
Flask>=3.0,<4
Flask-SQLAlchemy>=3.1,<4
Flask-Login>=0.6,<0.7
SQLAlchemy>=2.0,<3
python-dotenv>=1.0
requests>=2.32
APScheduler>=3.11,<4
waitress>=3.0

8
run_bot.py Normal file
View file

@ -0,0 +1,8 @@
"""Entrypoint: python run_bot.py"""
import asyncio
from bot.main import main
if __name__ == "__main__":
asyncio.run(main())

24
run_web.py Normal file
View file

@ -0,0 +1,24 @@
"""Production entrypoint for the Flask backend (API + admin + webapp static).
Dev: python bober_bbq/app.py (Flask debug server, auto-reload)
Prod: python run_web.py (waitress, no reload, multi-threaded)
"""
import os
from waitress import serve
from bober_bbq.app import create_app
from bober_bbq.extensions import db
from bober_bbq.seed import seed
app = create_app()
with app.app_context():
db.create_all()
seed()
if __name__ == "__main__":
port = int(os.getenv("PORT", "8000"))
print(f"Bober BBQ backend listening on http://0.0.0.0:{port}")
serve(app, host="0.0.0.0", port=port)

13
webapp/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<title>Bober BBQ</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1750
webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
webapp/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "bober-bbq-webapp",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

91
webapp/src/App.tsx Normal file
View file

@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import { api } from "./api";
import { BottomNav, type Tab } from "./components/BottomNav";
import { CartPage } from "./pages/CartPage";
import { ContactsPage } from "./pages/ContactsPage";
import { MenuPage } from "./pages/MenuPage";
import { getTelegram } from "./telegram";
import type { Cart, Category, Product, PublicSettings } from "./types";
const EMPTY_CART: Cart = { items: [], items_total: 0, count: 0 };
export default function App() {
const [tab, setTab] = useState<Tab>("menu");
const [categories, setCategories] = useState<Category[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [cart, setCart] = useState<Cart>(EMPTY_CART);
const [settings, setSettings] = useState<PublicSettings | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const tg = getTelegram();
tg?.ready();
tg?.expand();
Promise.all([api.categories(), api.products(), api.settings(), api.cart()])
.then(([cats, prods, sett, cartData]) => {
setCategories(cats);
setProducts(prods);
setSettings(sett);
setCart(cartData);
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
}, []);
const handleAdd = (productId: number, quantity: number) => {
api.addToCart(productId, quantity).then(setCart).catch(console.error);
};
const handleChangeQty = (productId: number, quantity: number) => {
api.setQuantity(productId, quantity).then(setCart).catch(console.error);
};
const handleRemove = (productId: number) => {
api.removeItem(productId).then(setCart).catch(console.error);
};
const handleClear = () => {
api.clearCart().then(setCart).catch(console.error);
};
if (loading) {
return <div className="loading">Завантаження меню</div>;
}
if (error) {
return (
<div className="empty-state">
Не вдалося завантажити дані.
<br />
{error}
<br />
Відкрийте цей екран через кнопку «Меню» в Telegram-боті.
</div>
);
}
return (
<div className="app">
<div className="header">
<div className="brand">🦫 Bober BBQ</div>
{settings && (
<div className={`status ${settings.is_open ? "open" : "closed"}`}>
{settings.is_open
? `Відкрито · до ${settings.work_hours_to}`
: `Зачинено · з ${settings.work_hours_from} до ${settings.work_hours_to}`}
</div>
)}
</div>
{tab === "menu" && <MenuPage categories={categories} products={products} onAdd={handleAdd} />}
{tab === "cart" && (
<CartPage cart={cart} onChangeQty={handleChangeQty} onRemove={handleRemove} onClear={handleClear} />
)}
{tab === "contacts" && settings && <ContactsPage settings={settings} />}
<BottomNav active={tab} cartCount={cart.count} onChange={setTab} />
</div>
);
}

42
webapp/src/api.ts Normal file
View file

@ -0,0 +1,42 @@
import { getInitData } from "./telegram";
import type { Cart, Category, Product, PublicSettings } from "./types";
const BASE = "/api";
function devUserParam(): string {
const id = new URLSearchParams(location.search).get("dev_user_id");
return id ? `dev_user_id=${id}` : "";
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const extra = devUserParam();
const url = extra ? `${BASE}${path}${path.includes("?") ? "&" : "?"}${extra}` : `${BASE}${path}`;
const res = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
"X-Telegram-Init-Data": getInitData(),
...(options.headers || {}),
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`API ${path} failed (${res.status}): ${text}`);
}
return res.json();
}
export const api = {
categories: () => request<Category[]>("/categories"),
products: () => request<Product[]>("/products"),
settings: () => request<PublicSettings>("/settings"),
cart: () => request<Cart>("/cart"),
addToCart: (product_id: number, quantity = 1) =>
request<Cart>("/cart/items", { method: "POST", body: JSON.stringify({ product_id, quantity }) }),
setQuantity: (product_id: number, quantity: number) =>
request<Cart>(`/cart/items/${product_id}`, { method: "PATCH", body: JSON.stringify({ quantity }) }),
removeItem: (product_id: number) => request<Cart>(`/cart/items/${product_id}`, { method: "DELETE" }),
clearCart: () => request<Cart>("/cart", { method: "DELETE" }),
};

View file

@ -0,0 +1,27 @@
export type Tab = "menu" | "cart" | "contacts";
interface Props {
active: Tab;
cartCount: number;
onChange: (tab: Tab) => void;
}
const TABS: { id: Tab; icon: string; label: string }[] = [
{ id: "menu", icon: "🍽", label: "Меню" },
{ id: "cart", icon: "🛒", label: "Кошик" },
{ id: "contacts", icon: "📞", label: "Контакти" },
];
export function BottomNav({ active, cartCount, onChange }: Props) {
return (
<nav className="bottom-nav">
{TABS.map((tab) => (
<button key={tab.id} className={active === tab.id ? "active" : ""} onClick={() => onChange(tab.id)}>
<span className="icon">{tab.icon}</span>
{tab.label}
{tab.id === "cart" && cartCount > 0 && <span className="badge">{cartCount}</span>}
</button>
))}
</nav>
);
}

View file

@ -0,0 +1,30 @@
import type { CartLine } from "../types";
interface Props {
item: CartLine;
onChangeQty: (productId: number, quantity: number) => void;
onRemove: (productId: number) => void;
}
export function CartItemRow({ item, onChangeQty, onRemove }: Props) {
return (
<div className="cart-item">
{item.image_url ? <img className="thumb" src={item.image_url} alt={item.name} /> : <div className="thumb" />}
<div className="info">
<div className="name">{item.name}</div>
<div className="sub">
{item.weight ? `${item.weight} · ` : ""}
{item.price} грн
</div>
</div>
<div className="stepper">
<button onClick={() => onChangeQty(item.product_id, item.quantity - 1)}></button>
<span className="qty">{item.quantity}</span>
<button onClick={() => onChangeQty(item.product_id, item.quantity + 1)}>+</button>
</div>
<button className="remove-btn" onClick={() => onRemove(item.product_id)}>
🗑
</button>
</div>
);
}

View file

@ -0,0 +1,23 @@
import type { Category } from "../types";
interface Props {
categories: Category[];
activeId: number | null;
onSelect: (id: number) => void;
}
export function CategoryTabs({ categories, activeId, onSelect }: Props) {
return (
<div className="category-tabs">
{categories.map((c) => (
<button
key={c.id}
className={`category-chip${c.id === activeId ? " active" : ""}`}
onClick={() => onSelect(c.id)}
>
{c.name}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,47 @@
import { useState } from "react";
import type { Product } from "../types";
import { haptic } from "../telegram";
interface Props {
product: Product;
onAdd: (productId: number, quantity: number) => void;
}
export function ProductCard({ product, onAdd }: Props) {
const [qty, setQty] = useState(1);
return (
<div className="product-card">
{product.image_url ? (
<img className="thumb" src={product.image_url} alt={product.name} />
) : (
<div className="thumb" />
)}
<div className="info">
<div className="name">{product.name}</div>
{product.description && <div className="desc">{product.description}</div>}
{product.weight && <div className="meta">{product.weight}</div>}
<div className="bottom-row">
<span className="price">{product.price} грн</span>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div className="stepper">
<button onClick={() => setQty((q) => Math.max(1, q - 1))}></button>
<span className="qty">{qty}</span>
<button onClick={() => setQty((q) => q + 1)}>+</button>
</div>
<button
className="add-btn"
onClick={() => {
onAdd(product.id, qty);
setQty(1);
haptic("light");
}}
>
Додати
</button>
</div>
</div>
</div>
</div>
);
}

10
webapp/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,52 @@
import { CartItemRow } from "../components/CartItemRow";
import { getTelegram, sendCheckoutAction } from "../telegram";
import type { Cart } from "../types";
interface Props {
cart: Cart;
onChangeQty: (productId: number, quantity: number) => void;
onRemove: (productId: number) => void;
onClear: () => void;
}
export function CartPage({ cart, onChangeQty, onRemove, onClear }: Props) {
const inTelegram = Boolean(getTelegram());
if (cart.items.length === 0) {
return <div className="empty-state">Ваш кошик порожній. Перейдіть до меню, щоб додати страви.</div>;
}
return (
<div className="cart-page">
{cart.items.map((item) => (
<CartItemRow key={item.product_id} item={item} onChangeQty={onChangeQty} onRemove={onRemove} />
))}
<div className="cart-summary">
<div className="total-row">
<span>Всього</span>
<span>{cart.items_total} грн</span>
</div>
{inTelegram ? (
<>
<button className="checkout-btn" onClick={() => sendCheckoutAction("checkout_delivery")}>
🚚 Оформити доставку
</button>
<button className="checkout-btn secondary" onClick={() => sendCheckoutAction("checkout_pickup")}>
🛍 Оформити самовивіз
</button>
</>
) : (
<p style={{ color: "var(--muted)", fontSize: 13, marginTop: 10 }}>
Відкрийте меню через Telegram-бота, щоб оформити замовлення.
</p>
)}
</div>
<div className="clear-link" onClick={onClear}>
Очистити кошик
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import type { PublicSettings } from "../types";
interface Props {
settings: PublicSettings;
}
export function ContactsPage({ settings }: Props) {
return (
<div className="contacts-page">
<div className="card">
<div className="row">
<div className="label">Заклад</div>
<div>{settings.cafe_name}</div>
</div>
<div className="row">
<div className="label">📞 Телефон</div>
<div>{settings.phone}</div>
</div>
<div className="row">
<div className="label">📍 Адреса</div>
<div>{settings.address}</div>
</div>
<div className="row">
<div className="label">🕐 Графік роботи</div>
<div>
Щодня з {settings.work_hours_from} до {settings.work_hours_to}
</div>
</div>
{settings.instagram_url && (
<div className="row">
<div className="label">📷 Instagram</div>
<a href={settings.instagram_url} target="_blank" rel="noreferrer">
{settings.instagram_url}
</a>
</div>
)}
{settings.maps_url && (
<div className="row">
<div className="label">🗺 Карта</div>
<a href={settings.maps_url} target="_blank" rel="noreferrer">
Відкрити на карті
</a>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,41 @@
import { useEffect, useMemo, useState } from "react";
import { CategoryTabs } from "../components/CategoryTabs";
import { ProductCard } from "../components/ProductCard";
import type { Category, Product } from "../types";
interface Props {
categories: Category[];
products: Product[];
onAdd: (productId: number, quantity: number) => void;
}
export function MenuPage({ categories, products, onAdd }: Props) {
const [activeId, setActiveId] = useState<number | null>(null);
useEffect(() => {
if (activeId === null && categories.length > 0) {
setActiveId(categories[0].id);
}
}, [categories, activeId]);
const visibleProducts = useMemo(
() => products.filter((p) => p.category_id === activeId),
[products, activeId]
);
if (categories.length === 0) {
return <div className="empty-state">Меню поки порожнє. Загляньте пізніше 🦫</div>;
}
return (
<>
<CategoryTabs categories={categories} activeId={activeId} onSelect={setActiveId} />
<div className="product-list">
{visibleProducts.map((p) => (
<ProductCard key={p.id} product={p} onAdd={onAdd} />
))}
{visibleProducts.length === 0 && <div className="empty-state">У цій категорії поки немає страв</div>}
</div>
</>
);
}

328
webapp/src/styles.css Normal file
View file

@ -0,0 +1,328 @@
:root {
--bg: #14100e;
--panel: #1d1712;
--panel-2: #241b15;
--border: #33261c;
--text: #f2e9e0;
--muted: #b8a99a;
--accent: #f97316;
--accent-dark: #c2570a;
--success: #22c55e;
--danger: #ef4444;
--radius: 14px;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body, #root { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overscroll-behavior-y: contain;
}
img { max-width: 100%; display: block; }
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
.header {
padding: 14px 16px 8px;
position: sticky;
top: 0;
background: var(--bg);
z-index: 5;
}
.header .brand {
font-size: 20px;
font-weight: 800;
color: var(--accent);
}
.header .status {
font-size: 12px;
color: var(--muted);
margin-top: 2px;
}
.status.closed { color: var(--danger); }
.status.open { color: var(--success); }
.category-tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 10px 16px 4px;
scrollbar-width: none;
}
.category-tabs::-webkit-scrollbar { display: none; }
.category-chip {
flex-shrink: 0;
padding: 8px 16px;
border-radius: 20px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--muted);
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.category-chip.active {
background: var(--accent);
color: #1b0f00;
border-color: var(--accent);
}
.product-list {
padding: 8px 16px 16px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.product-card {
display: flex;
gap: 12px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px;
}
.product-card .thumb {
width: 76px;
height: 76px;
border-radius: 10px;
background: var(--panel-2);
object-fit: cover;
flex-shrink: 0;
}
.product-card .info {
flex: 1;
min-width: 0;
}
.product-card .name {
font-weight: 700;
font-size: 15px;
}
.product-card .desc {
font-size: 12px;
color: var(--muted);
margin-top: 2px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.product-card .meta {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
.product-card .bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.price { font-weight: 700; color: var(--accent); }
.stepper {
display: flex;
align-items: center;
gap: 10px;
background: var(--panel-2);
border-radius: 20px;
padding: 4px 6px;
}
.stepper button {
width: 26px;
height: 26px;
border-radius: 50%;
border: none;
background: var(--accent);
color: #1b0f00;
font-size: 16px;
font-weight: 700;
line-height: 1;
cursor: pointer;
}
.stepper .qty { min-width: 16px; text-align: center; font-weight: 700; }
.add-btn {
background: var(--accent);
color: #1b0f00;
border: none;
border-radius: 20px;
padding: 7px 16px;
font-weight: 700;
font-size: 13px;
cursor: pointer;
}
.cart-page, .contacts-page {
padding: 12px 16px 20px;
flex: 1;
}
.cart-item {
display: flex;
align-items: center;
gap: 10px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px;
margin-bottom: 10px;
}
.cart-item .thumb {
width: 52px;
height: 52px;
border-radius: 8px;
background: var(--panel-2);
object-fit: cover;
flex-shrink: 0;
}
.cart-item .info { flex: 1; min-width: 0; }
.cart-item .name { font-weight: 600; font-size: 14px; }
.cart-item .sub { font-size: 12px; color: var(--muted); margin-top: 2px; }
.remove-btn {
background: none;
border: none;
color: var(--muted);
font-size: 18px;
cursor: pointer;
padding: 4px 6px;
}
.cart-summary {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
margin-top: 12px;
}
.cart-summary .total-row {
display: flex;
justify-content: space-between;
font-size: 17px;
font-weight: 700;
}
.checkout-btn {
display: block;
width: 100%;
background: var(--accent);
color: #1b0f00;
border: none;
border-radius: var(--radius);
padding: 13px;
font-weight: 700;
font-size: 15px;
margin-top: 10px;
cursor: pointer;
}
.checkout-btn.secondary {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
}
.clear-link {
text-align: center;
color: var(--muted);
font-size: 13px;
margin-top: 14px;
cursor: pointer;
text-decoration: underline;
}
.empty-state {
text-align: center;
color: var(--muted);
padding: 60px 20px;
font-size: 14px;
}
.contacts-page .card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.contacts-page .row { margin-bottom: 12px; font-size: 14px; }
.contacts-page .label { color: var(--muted); font-size: 12px; margin-bottom: 2px; }
.contacts-page a { color: var(--accent); }
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: var(--panel);
border-top: 1px solid var(--border);
padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
z-index: 10;
}
.bottom-nav button {
flex: 1;
background: none;
border: none;
color: var(--muted);
font-size: 11px;
font-weight: 600;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
cursor: pointer;
position: relative;
}
.bottom-nav button.active { color: var(--accent); }
.bottom-nav .icon { font-size: 20px; }
.badge {
position: absolute;
top: -4px;
right: 22%;
background: var(--accent);
color: #1b0f00;
font-size: 10px;
font-weight: 700;
border-radius: 10px;
padding: 1px 5px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 60vh;
color: var(--muted);
}

56
webapp/src/telegram.ts Normal file
View file

@ -0,0 +1,56 @@
export interface TelegramWebApp {
initData: string;
initDataUnsafe: { user?: { id: number; first_name?: string; username?: string } };
ready: () => void;
expand: () => void;
close: () => void;
sendData: (data: string) => void;
colorScheme: "light" | "dark";
themeParams: Record<string, string>;
MainButton: {
text: string;
isVisible: boolean;
show: () => void;
hide: () => void;
onClick: (cb: () => void) => void;
offClick: (cb: () => void) => void;
setText: (text: string) => void;
enable: () => void;
disable: () => void;
};
BackButton: {
isVisible: boolean;
show: () => void;
hide: () => void;
onClick: (cb: () => void) => void;
offClick: (cb: () => void) => void;
};
HapticFeedback?: {
impactOccurred: (style: "light" | "medium" | "heavy" | "rigid" | "soft") => void;
};
}
declare global {
interface Window {
Telegram?: { WebApp: TelegramWebApp };
}
}
export function getTelegram(): TelegramWebApp | null {
return typeof window !== "undefined" && window.Telegram ? window.Telegram.WebApp : null;
}
export function getInitData(): string {
return getTelegram()?.initData ?? "";
}
export function haptic(style: "light" | "medium" | "heavy" = "light") {
getTelegram()?.HapticFeedback?.impactOccurred(style);
}
export function sendCheckoutAction(action: "checkout_delivery" | "checkout_pickup") {
const tg = getTelegram();
if (!tg) return;
tg.sendData(JSON.stringify({ action }));
tg.close();
}

46
webapp/src/types.ts Normal file
View file

@ -0,0 +1,46 @@
export interface Category {
id: number;
name: string;
slug: string;
sort_order: number;
}
export interface Product {
id: number;
category_id: number;
name: string;
description: string;
weight: string;
price: number;
image_url: string;
}
export interface CartLine {
product_id: number;
name: string;
weight: string;
image_url: string;
price: number;
quantity: number;
line_total: number;
}
export interface Cart {
items: CartLine[];
items_total: number;
count: number;
}
export interface PublicSettings {
cafe_name: string;
phone: string;
address: string;
work_hours_from: string;
work_hours_to: string;
is_open: boolean;
instagram_url: string;
maps_url: string;
delivery_fee: number;
free_delivery_threshold: number;
pickup_discount_percent: number;
}

1
webapp/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

18
webapp/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}

18
webapp/vite.config.ts Normal file
View file

@ -0,0 +1,18 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// Served by the Flask backend under /webapp/, so the build must use that
// as its base path for asset URLs to resolve correctly.
export default defineConfig({
plugins: [react()],
base: "/webapp/",
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8000",
},
},
build: {
outDir: "dist",
},
});