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:
commit
0eabebaca3
78 changed files with 5917 additions and 0 deletions
36
.env.example
Normal file
36
.env.example
Normal 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
11
.gitignore
vendored
Normal 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
103
README.md
Normal 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
1
bober_bbq/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Bober BBQ — shared backend package (Flask API + admin) used by both the web app and the Telegram bot."""
|
||||
5
bober_bbq/admin/__init__.py
Normal file
5
bober_bbq/admin/__init__.py
Normal 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
274
bober_bbq/admin/routes.py
Normal 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
12
bober_bbq/api/__init__.py
Normal 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
43
bober_bbq/api/auth.py
Normal 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
86
bober_bbq/api/cart.py
Normal 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
17
bober_bbq/api/menu.py
Normal 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
52
bober_bbq/api/payments.py
Normal 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
26
bober_bbq/api/settings.py
Normal 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
86
bober_bbq/app.py
Normal 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
51
bober_bbq/config.py
Normal 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
6
bober_bbq/extensions.py
Normal 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
246
bober_bbq/models.py
Normal 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)
|
||||
0
bober_bbq/payments/__init__.py
Normal file
0
bober_bbq/payments/__init__.py
Normal file
120
bober_bbq/payments/monobank.py
Normal file
120
bober_bbq/payments/monobank.py
Normal 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
95
bober_bbq/seed.py
Normal 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.")
|
||||
140
bober_bbq/static/admin/css/admin.css
Normal file
140
bober_bbq/static/admin/css/admin.css
Normal 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; }
|
||||
0
bober_bbq/static/uploads/.gitkeep
Normal file
0
bober_bbq/static/uploads/.gitkeep
Normal file
36
bober_bbq/templates/admin/base.html
Normal file
36
bober_bbq/templates/admin/base.html
Normal 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>
|
||||
47
bober_bbq/templates/admin/categories.html
Normal file
47
bober_bbq/templates/admin/categories.html
Normal 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 %}
|
||||
32
bober_bbq/templates/admin/dashboard.html
Normal file
32
bober_bbq/templates/admin/dashboard.html
Normal 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 %}
|
||||
20
bober_bbq/templates/admin/login.html
Normal file
20
bober_bbq/templates/admin/login.html
Normal 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 %}
|
||||
58
bober_bbq/templates/admin/order_detail.html
Normal file
58
bober_bbq/templates/admin/order_detail.html
Normal 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 %}
|
||||
41
bober_bbq/templates/admin/orders.html
Normal file
41
bober_bbq/templates/admin/orders.html
Normal 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 %}
|
||||
51
bober_bbq/templates/admin/product_form.html
Normal file
51
bober_bbq/templates/admin/product_form.html
Normal 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 %}
|
||||
43
bober_bbq/templates/admin/products.html
Normal file
43
bober_bbq/templates/admin/products.html
Normal 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 %}
|
||||
45
bober_bbq/templates/admin/settings.html
Normal file
45
bober_bbq/templates/admin/settings.html
Normal 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 %}
|
||||
0
bober_bbq/utils/__init__.py
Normal file
0
bober_bbq/utils/__init__.py
Normal file
79
bober_bbq/utils/formatting.py
Normal file
79
bober_bbq/utils/formatting.py
Normal 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)
|
||||
23
bober_bbq/utils/pricing.py
Normal file
23
bober_bbq/utils/pricing.py
Normal 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)
|
||||
69
bober_bbq/utils/telegram_auth.py
Normal file
69
bober_bbq/utils/telegram_auth.py
Normal 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"}}
|
||||
27
bober_bbq/utils/telegram_notify.py
Normal file
27
bober_bbq/utils/telegram_notify.py
Normal 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
|
||||
51
bober_bbq/utils/workhours.py
Normal file
51
bober_bbq/utils/workhours.py
Normal 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
0
bot/__init__.py
Normal file
0
bot/handlers/__init__.py
Normal file
0
bot/handlers/__init__.py
Normal file
71
bot/handlers/cart.py
Normal file
71
bot/handlers/cart.py
Normal 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
35
bot/handlers/contacts.py
Normal 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
185
bot/handlers/delivery.py
Normal 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
19
bot/handlers/menu.py
Normal 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
63
bot/handlers/payment.py
Normal 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
174
bot/handlers/pickup.py
Normal 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
25
bot/handlers/start.py
Normal 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())
|
||||
37
bot/handlers/webapp_data.py
Normal file
37
bot/handlers/webapp_data.py
Normal 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
124
bot/keyboards.py
Normal 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
49
bot/main.py
Normal 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
0
bot/services/__init__.py
Normal file
54
bot/services/cart_service.py
Normal file
54
bot/services/cart_service.py
Normal 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
34
bot/services/notify.py
Normal 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())
|
||||
93
bot/services/order_service.py
Normal file
93
bot/services/order_service.py
Normal 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
|
||||
33
bot/services/payment_flow.py
Normal file
33
bot/services/payment_flow.py
Normal 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
21
bot/states.py
Normal 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
134
docs/DEPLOYMENT.md
Normal 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
30
docs/OPEN_QUESTIONS.md
Normal 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
9
requirements.txt
Normal 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
8
run_bot.py
Normal 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
24
run_web.py
Normal 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
13
webapp/index.html
Normal 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
1750
webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
webapp/package.json
Normal file
22
webapp/package.json
Normal 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
91
webapp/src/App.tsx
Normal 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
42
webapp/src/api.ts
Normal 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" }),
|
||||
};
|
||||
27
webapp/src/components/BottomNav.tsx
Normal file
27
webapp/src/components/BottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
webapp/src/components/CartItemRow.tsx
Normal file
30
webapp/src/components/CartItemRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
webapp/src/components/CategoryTabs.tsx
Normal file
23
webapp/src/components/CategoryTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
webapp/src/components/ProductCard.tsx
Normal file
47
webapp/src/components/ProductCard.tsx
Normal 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
10
webapp/src/main.tsx
Normal 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>
|
||||
);
|
||||
52
webapp/src/pages/CartPage.tsx
Normal file
52
webapp/src/pages/CartPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
webapp/src/pages/ContactsPage.tsx
Normal file
48
webapp/src/pages/ContactsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
webapp/src/pages/MenuPage.tsx
Normal file
41
webapp/src/pages/MenuPage.tsx
Normal 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
328
webapp/src/styles.css
Normal 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
56
webapp/src/telegram.ts
Normal 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
46
webapp/src/types.ts
Normal 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
1
webapp/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
18
webapp/tsconfig.json
Normal file
18
webapp/tsconfig.json
Normal 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
18
webapp/vite.config.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue