commit 0eabebaca3a3fe6d949009ceccce0056ecdf39aa Author: byrsapty Date: Sat Aug 8 15:48:06 2026 +0300 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..80bd731 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5199b22 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5aa329f --- /dev/null +++ b/README.md @@ -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). diff --git a/bober_bbq/__init__.py b/bober_bbq/__init__.py new file mode 100644 index 0000000..2e2df3e --- /dev/null +++ b/bober_bbq/__init__.py @@ -0,0 +1 @@ +"""Bober BBQ — shared backend package (Flask API + admin) used by both the web app and the Telegram bot.""" diff --git a/bober_bbq/admin/__init__.py b/bober_bbq/admin/__init__.py new file mode 100644 index 0000000..257a83d --- /dev/null +++ b/bober_bbq/admin/__init__.py @@ -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) diff --git a/bober_bbq/admin/routes.py b/bober_bbq/admin/routes.py new file mode 100644 index 0000000..2e0c9e5 --- /dev/null +++ b/bober_bbq/admin/routes.py @@ -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//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//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//move/", 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//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//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//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/") +@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//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 diff --git a/bober_bbq/api/__init__.py b/bober_bbq/api/__init__.py new file mode 100644 index 0000000..3b4583f --- /dev/null +++ b/bober_bbq/api/__init__.py @@ -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) diff --git a/bober_bbq/api/auth.py b/bober_bbq/api/auth.py new file mode 100644 index 0000000..435cc64 --- /dev/null +++ b/bober_bbq/api/auth.py @@ -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 diff --git a/bober_bbq/api/cart.py b/bober_bbq/api/cart.py new file mode 100644 index 0000000..e884ace --- /dev/null +++ b/bober_bbq/api/cart.py @@ -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/") +@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/") +@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()) diff --git a/bober_bbq/api/menu.py b/bober_bbq/api/menu.py new file mode 100644 index 0000000..f7295ba --- /dev/null +++ b/bober_bbq/api/menu.py @@ -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]) diff --git a/bober_bbq/api/payments.py b/bober_bbq/api/payments.py new file mode 100644 index 0000000..9bae170 --- /dev/null +++ b/bober_bbq/api/payments.py @@ -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}) diff --git a/bober_bbq/api/settings.py b/bober_bbq/api/settings.py new file mode 100644 index 0000000..089f38f --- /dev/null +++ b/bober_bbq/api/settings.py @@ -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"), + } + ) diff --git a/bober_bbq/app.py b/bober_bbq/app.py new file mode 100644 index 0000000..7eae0c2 --- /dev/null +++ b/bober_bbq/app.py @@ -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( + """ + + + Дякуємо + +
+

Дякуємо за оплату!

+

Можете повернутись у Telegram-бот — статус замовлення оновиться автоматично.

+
+ """ + ) + + # Serve the built Mini App (Vite `npm run build` output) under /webapp/ + @app.route("/webapp/") + @app.route("/webapp/") + 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) diff --git a/bober_bbq/config.py b/bober_bbq/config.py new file mode 100644 index 0000000..d4a37ab --- /dev/null +++ b/bober_bbq/config.py @@ -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() diff --git a/bober_bbq/extensions.py b/bober_bbq/extensions.py new file mode 100644 index 0000000..9362226 --- /dev/null +++ b/bober_bbq/extensions.py @@ -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" diff --git a/bober_bbq/models.py b/bober_bbq/models.py new file mode 100644 index 0000000..77b1d1f --- /dev/null +++ b/bober_bbq/models.py @@ -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) diff --git a/bober_bbq/payments/__init__.py b/bober_bbq/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bober_bbq/payments/monobank.py b/bober_bbq/payments/monobank.py new file mode 100644 index 0000000..8a8dd37 --- /dev/null +++ b/bober_bbq/payments/monobank.py @@ -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"} diff --git a/bober_bbq/seed.py b/bober_bbq/seed.py new file mode 100644 index 0000000..f112337 --- /dev/null +++ b/bober_bbq/seed.py @@ -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.") diff --git a/bober_bbq/static/admin/css/admin.css b/bober_bbq/static/admin/css/admin.css new file mode 100644 index 0000000..0b315ff --- /dev/null +++ b/bober_bbq/static/admin/css/admin.css @@ -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; } diff --git a/bober_bbq/static/uploads/.gitkeep b/bober_bbq/static/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/bober_bbq/templates/admin/base.html b/bober_bbq/templates/admin/base.html new file mode 100644 index 0000000..e639fcf --- /dev/null +++ b/bober_bbq/templates/admin/base.html @@ -0,0 +1,36 @@ + + + + + + {% block title %}Bober BBQ — Адмін{% endblock %} + + + + {% if current_user.is_authenticated %} +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+
+ {% else %} + {% block guest_content %}{% endblock %} + {% endif %} + + diff --git a/bober_bbq/templates/admin/categories.html b/bober_bbq/templates/admin/categories.html new file mode 100644 index 0000000..0b39487 --- /dev/null +++ b/bober_bbq/templates/admin/categories.html @@ -0,0 +1,47 @@ +{% extends "admin/base.html" %} +{% block content %} +

Категорії

+ +
+ + + {% for c in categories %} + + + + + + + + {% else %} + + {% endfor %} +
ПорядокНазваSlugАктивна
+
+
+
+
+ + + +
+
{{ c.slug }}{{ '✅' if c.is_active else '—' }} +
+ +
+
Категорій ще немає
+
+ +

Додати категорію

+
+
+
+ + +
+ +
+
+{% endblock %} diff --git a/bober_bbq/templates/admin/dashboard.html b/bober_bbq/templates/admin/dashboard.html new file mode 100644 index 0000000..f0b06e1 --- /dev/null +++ b/bober_bbq/templates/admin/dashboard.html @@ -0,0 +1,32 @@ +{% extends "admin/base.html" %} +{% block content %} +

Дашборд

+ +
+
{{ stats.new_orders }}
Нові замовлення
+
{{ stats.total_orders }}
Усього замовлень
+
{{ stats.products }}
Товарів
+
{{ stats.categories }}
Категорій
+
+ +

Останні замовлення

+
+ + + {% for o in orders %} + + + + + + + + + + + {% else %} + + {% endfor %} +
ТипКлієнтСумаОплатаСтатусЧас
{{ o.display_number() }}{{ 'Доставка' if o.order_type == 'delivery' else 'Самовивіз' }}{{ o.customer_name }}{{ o.total }} грн{{ 'Оплачено' if o.payment_status == 'paid' else 'Не оплачено' }}{{ o.status }}{{ o.created_at.strftime('%d.%m %H:%M') if o.created_at else '' }}Відкрити
Замовлень ще немає
+
+{% endblock %} diff --git a/bober_bbq/templates/admin/login.html b/bober_bbq/templates/admin/login.html new file mode 100644 index 0000000..1d4f866 --- /dev/null +++ b/bober_bbq/templates/admin/login.html @@ -0,0 +1,20 @@ +{% extends "admin/base.html" %} +{% block guest_content %} + +{% endblock %} diff --git a/bober_bbq/templates/admin/order_detail.html b/bober_bbq/templates/admin/order_detail.html new file mode 100644 index 0000000..931302f --- /dev/null +++ b/bober_bbq/templates/admin/order_detail.html @@ -0,0 +1,58 @@ +{% extends "admin/base.html" %} +{% block content %} +

Замовлення {{ order.display_number() }}

+ +
+ + + + + {% if order.order_type == 'delivery' %} + + {% else %} + + {% endif %} + + + +
Тип{{ 'Доставка' if order.order_type == 'delivery' else 'Самовивіз' }}
Клієнт{{ order.customer_name }}
Телефон{{ order.customer_phone }}
Адреса + {{ 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 %}
{{ order.address_comment }}{% endif %} +
Час прибуття{{ order.pickup_time }}
Метод оплати{{ 'Картка' 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 %} +
Оплата{{ 'Оплачено' if order.payment_status == 'paid' else 'Не оплачено' }}
Створено{{ order.created_at.strftime('%d.%m.%Y %H:%M') if order.created_at else '' }}
+
+ +

Товари

+
+ + + {% for item in order.items %} + + + + + + + {% endfor %} +
ТоварК-стьЦінаСума
{{ item.product_name }}{{ item.quantity }}{{ item.product_price }} грн{{ item.line_total }} грн
+
+
Сума товарів: {{ order.items_total }} грн
+ {% if order.delivery_fee %}
Доставка: {{ order.delivery_fee }} грн
{% endif %} + {% if order.discount_amount %}
Знижка: −{{ order.discount_amount }} грн
{% endif %} +
Разом: {{ order.total }} грн
+
+
+ +

Статус

+
+
+ + +
+
+{% endblock %} diff --git a/bober_bbq/templates/admin/orders.html b/bober_bbq/templates/admin/orders.html new file mode 100644 index 0000000..54572a8 --- /dev/null +++ b/bober_bbq/templates/admin/orders.html @@ -0,0 +1,41 @@ +{% extends "admin/base.html" %} +{% block content %} +

Замовлення

+ +
+
+ + +
+
+ +
+ + + {% for o in orders %} + + + + + + + + + + + + {% else %} + + {% endfor %} +
ТипКлієнтТелефонСумаОплатаСтатусЧас
{{ o.display_number() }}{{ 'Доставка' if o.order_type == 'delivery' else 'Самовивіз' }}{{ o.customer_name }}{{ o.customer_phone }}{{ o.total }} грн{{ 'Оплачено' if o.payment_status == 'paid' else 'Не оплачено' }}{{ o.status }}{{ o.created_at.strftime('%d.%m %H:%M') if o.created_at else '' }}Відкрити
Немає замовлень за цим фільтром
+
+{% endblock %} diff --git a/bober_bbq/templates/admin/product_form.html b/bober_bbq/templates/admin/product_form.html new file mode 100644 index 0000000..14127cd --- /dev/null +++ b/bober_bbq/templates/admin/product_form.html @@ -0,0 +1,51 @@ +{% extends "admin/base.html" %} +{% block content %} +

{{ 'Редагувати товар' if product else 'Новий товар' }}

+ +
+
+ + + + + + + + + +
+
+ + +
+
+ + +
+
+ + + {% if product and product.image_url %} + + {% endif %} + + + + + + + +
+ + Скасувати +
+
+
+{% endblock %} diff --git a/bober_bbq/templates/admin/products.html b/bober_bbq/templates/admin/products.html new file mode 100644 index 0000000..67c6851 --- /dev/null +++ b/bober_bbq/templates/admin/products.html @@ -0,0 +1,43 @@ +{% extends "admin/base.html" %} +{% block content %} +

Товари

+ +
+
+ +
+ + Новий товар +
+ +
+ + + {% for p in products %} + + + + + + + + + + {% else %} + + {% endfor %} +
НазваКатегоріяВагаЦінаАктивний
{% if p.image_url %}{% else %}
{% endif %}
{{ p.name }}{{ p.category.name }}{{ p.weight }}{{ p.price }} грн{{ '✅' if p.is_active else '🚫' }} + Редагувати +
+ +
+
+ +
+
Товарів ще немає
+
+{% endblock %} diff --git a/bober_bbq/templates/admin/settings.html b/bober_bbq/templates/admin/settings.html new file mode 100644 index 0000000..b674bb3 --- /dev/null +++ b/bober_bbq/templates/admin/settings.html @@ -0,0 +1,45 @@ +{% extends "admin/base.html" %} +{% block content %} +

Налаштування

+ +
+
+

Заклад

+ + + + + + +
+
+
+
+ + + + + + +

Доставка та самовивіз

+
+
+
+
+ + + +

Telegram-чати для замовлень

+ + + + +

Бот має бути доданий у ці чати як адміністратор. ID чату можна дізнатись, переславши будь-яке повідомлення з чату боту @userinfobot або аналогічному.

+ + +
+
+{% endblock %} diff --git a/bober_bbq/utils/__init__.py b/bober_bbq/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bober_bbq/utils/formatting.py b/bober_bbq/utils/formatting.py new file mode 100644 index 0000000..dcd164d --- /dev/null +++ b/bober_bbq/utils/formatting.py @@ -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) diff --git a/bober_bbq/utils/pricing.py b/bober_bbq/utils/pricing.py new file mode 100644 index 0000000..3352f2d --- /dev/null +++ b/bober_bbq/utils/pricing.py @@ -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) diff --git a/bober_bbq/utils/telegram_auth.py b/bober_bbq/utils/telegram_auth.py new file mode 100644 index 0000000..295cd9e --- /dev/null +++ b/bober_bbq/utils/telegram_auth.py @@ -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"}} diff --git a/bober_bbq/utils/telegram_notify.py b/bober_bbq/utils/telegram_notify.py new file mode 100644 index 0000000..1b7bbcf --- /dev/null +++ b/bober_bbq/utils/telegram_notify.py @@ -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 diff --git a/bober_bbq/utils/workhours.py b/bober_bbq/utils/workhours.py new file mode 100644 index 0000000..1b0a98b --- /dev/null +++ b/bober_bbq/utils/workhours.py @@ -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"Ви можете переглянути меню зараз, а оформити замовлення під час роботи закладу." + ) diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/cart.py b/bot/handlers/cart.py new file mode 100644 index 0000000..7bf0e9c --- /dev/null +++ b/bot/handlers/cart.py @@ -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) diff --git a/bot/handlers/contacts.py b/bot/handlers/contacts.py new file mode 100644 index 0000000..3a68cdf --- /dev/null +++ b/bot/handlers/contacts.py @@ -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()) diff --git a/bot/handlers/delivery.py b/bot/handlers/delivery.py new file mode 100644 index 0000000..0f9fe48 --- /dev/null +++ b/bot/handlers/delivery.py @@ -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) diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py new file mode 100644 index 0000000..d0a0922 --- /dev/null +++ b/bot/handlers/menu.py @@ -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) diff --git a/bot/handlers/payment.py b/bot/handlers/payment.py new file mode 100644 index 0000000..bc49888 --- /dev/null +++ b/bot/handlers/payment.py @@ -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() diff --git a/bot/handlers/pickup.py b/bot/handlers/pickup.py new file mode 100644 index 0000000..ef25170 --- /dev/null +++ b/bot/handlers/pickup.py @@ -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) diff --git a/bot/handlers/start.py b/bot/handlers/start.py new file mode 100644 index 0000000..6cd8c4d --- /dev/null +++ b/bot/handlers/start.py @@ -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()) diff --git a/bot/handlers/webapp_data.py b/bot/handlers/webapp_data.py new file mode 100644 index 0000000..75d05e0 --- /dev/null +++ b/bot/handlers/webapp_data.py @@ -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) diff --git a/bot/keyboards.py b/bot/keyboards.py new file mode 100644 index 0000000..31e864d --- /dev/null +++ b/bot/keyboards.py @@ -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) diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..8f67bb4 --- /dev/null +++ b/bot/main.py @@ -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()) diff --git a/bot/services/__init__.py b/bot/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/services/cart_service.py b/bot/services/cart_service.py new file mode 100644 index 0000000..6dbf37c --- /dev/null +++ b/bot/services/cart_service.py @@ -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) diff --git a/bot/services/notify.py b/bot/services/notify.py new file mode 100644 index 0000000..5ca4ece --- /dev/null +++ b/bot/services/notify.py @@ -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()) diff --git a/bot/services/order_service.py b/bot/services/order_service.py new file mode 100644 index 0000000..54010b2 --- /dev/null +++ b/bot/services/order_service.py @@ -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 diff --git a/bot/services/payment_flow.py b/bot/services/payment_flow.py new file mode 100644 index 0000000..20b84c7 --- /dev/null +++ b/bot/services/payment_flow.py @@ -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), + ) diff --git a/bot/states.py b/bot/states.py new file mode 100644 index 0000000..89e49ad --- /dev/null +++ b/bot/states.py @@ -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() diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..a0a0567 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -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 +``` diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md new file mode 100644 index 0000000..adfd16a --- /dev/null +++ b/docs/OPEN_QUESTIONS.md @@ -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-перевірку, якщо + потрібно жорсткіше. +- **Мова**: інтерфейс лише українською, як і вимагає ТЗ — мультимовність не + закладена. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..59b80bc --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run_bot.py b/run_bot.py new file mode 100644 index 0000000..c7d6edc --- /dev/null +++ b/run_bot.py @@ -0,0 +1,8 @@ +"""Entrypoint: python run_bot.py""" + +import asyncio + +from bot.main import main + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/run_web.py b/run_web.py new file mode 100644 index 0000000..e12b51b --- /dev/null +++ b/run_web.py @@ -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) diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..f805d3c --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,13 @@ + + + + + + Bober BBQ + + + +
+ + + diff --git a/webapp/package-lock.json b/webapp/package-lock.json new file mode 100644 index 0000000..9905ee0 --- /dev/null +++ b/webapp/package-lock.json @@ -0,0 +1,1750 @@ +{ + "name": "bober-bbq-webapp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bober-bbq-webapp", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", + "integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/webapp/package.json b/webapp/package.json new file mode 100644 index 0000000..612d208 --- /dev/null +++ b/webapp/package.json @@ -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" + } +} diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx new file mode 100644 index 0000000..c5c8b9d --- /dev/null +++ b/webapp/src/App.tsx @@ -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("menu"); + const [categories, setCategories] = useState([]); + const [products, setProducts] = useState([]); + const [cart, setCart] = useState(EMPTY_CART); + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
Завантаження меню…
; + } + + if (error) { + return ( +
+ Не вдалося завантажити дані. +
+ {error} +
+ Відкрийте цей екран через кнопку «Меню» в Telegram-боті. +
+ ); + } + + return ( +
+
+
🦫 Bober BBQ
+ {settings && ( +
+ {settings.is_open + ? `Відкрито · до ${settings.work_hours_to}` + : `Зачинено · з ${settings.work_hours_from} до ${settings.work_hours_to}`} +
+ )} +
+ + {tab === "menu" && } + {tab === "cart" && ( + + )} + {tab === "contacts" && settings && } + + +
+ ); +} diff --git a/webapp/src/api.ts b/webapp/src/api.ts new file mode 100644 index 0000000..7aeb67c --- /dev/null +++ b/webapp/src/api.ts @@ -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(path: string, options: RequestInit = {}): Promise { + 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("/categories"), + products: () => request("/products"), + settings: () => request("/settings"), + cart: () => request("/cart"), + addToCart: (product_id: number, quantity = 1) => + request("/cart/items", { method: "POST", body: JSON.stringify({ product_id, quantity }) }), + setQuantity: (product_id: number, quantity: number) => + request(`/cart/items/${product_id}`, { method: "PATCH", body: JSON.stringify({ quantity }) }), + removeItem: (product_id: number) => request(`/cart/items/${product_id}`, { method: "DELETE" }), + clearCart: () => request("/cart", { method: "DELETE" }), +}; diff --git a/webapp/src/components/BottomNav.tsx b/webapp/src/components/BottomNav.tsx new file mode 100644 index 0000000..50aee48 --- /dev/null +++ b/webapp/src/components/BottomNav.tsx @@ -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 ( + + ); +} diff --git a/webapp/src/components/CartItemRow.tsx b/webapp/src/components/CartItemRow.tsx new file mode 100644 index 0000000..5206cb8 --- /dev/null +++ b/webapp/src/components/CartItemRow.tsx @@ -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 ( +
+ {item.image_url ? {item.name} :
} +
+
{item.name}
+
+ {item.weight ? `${item.weight} · ` : ""} + {item.price} грн +
+
+
+ + {item.quantity} + +
+ +
+ ); +} diff --git a/webapp/src/components/CategoryTabs.tsx b/webapp/src/components/CategoryTabs.tsx new file mode 100644 index 0000000..6e8788d --- /dev/null +++ b/webapp/src/components/CategoryTabs.tsx @@ -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 ( +
+ {categories.map((c) => ( + + ))} +
+ ); +} diff --git a/webapp/src/components/ProductCard.tsx b/webapp/src/components/ProductCard.tsx new file mode 100644 index 0000000..ec4a7c6 --- /dev/null +++ b/webapp/src/components/ProductCard.tsx @@ -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 ( +
+ {product.image_url ? ( + {product.name} + ) : ( +
+ )} +
+
{product.name}
+ {product.description &&
{product.description}
} + {product.weight &&
{product.weight}
} +
+ {product.price} грн +
+
+ + {qty} + +
+ +
+
+
+
+ ); +} diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx new file mode 100644 index 0000000..16958a2 --- /dev/null +++ b/webapp/src/main.tsx @@ -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( + + + +); diff --git a/webapp/src/pages/CartPage.tsx b/webapp/src/pages/CartPage.tsx new file mode 100644 index 0000000..a5c8550 --- /dev/null +++ b/webapp/src/pages/CartPage.tsx @@ -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
Ваш кошик порожній. Перейдіть до меню, щоб додати страви.
; + } + + return ( +
+ {cart.items.map((item) => ( + + ))} + +
+
+ Всього + {cart.items_total} грн +
+ + {inTelegram ? ( + <> + + + + ) : ( +

+ Відкрийте меню через Telegram-бота, щоб оформити замовлення. +

+ )} +
+ +
+ Очистити кошик +
+
+ ); +} diff --git a/webapp/src/pages/ContactsPage.tsx b/webapp/src/pages/ContactsPage.tsx new file mode 100644 index 0000000..5af4927 --- /dev/null +++ b/webapp/src/pages/ContactsPage.tsx @@ -0,0 +1,48 @@ +import type { PublicSettings } from "../types"; + +interface Props { + settings: PublicSettings; +} + +export function ContactsPage({ settings }: Props) { + return ( +
+
+
+
Заклад
+
{settings.cafe_name}
+
+
+
📞 Телефон
+
{settings.phone}
+
+
+
📍 Адреса
+
{settings.address}
+
+
+
🕐 Графік роботи
+
+ Щодня з {settings.work_hours_from} до {settings.work_hours_to} +
+
+ {settings.instagram_url && ( +
+
📷 Instagram
+ + {settings.instagram_url} + +
+ )} + {settings.maps_url && ( + + )} +
+
+ ); +} diff --git a/webapp/src/pages/MenuPage.tsx b/webapp/src/pages/MenuPage.tsx new file mode 100644 index 0000000..bd3eb5b --- /dev/null +++ b/webapp/src/pages/MenuPage.tsx @@ -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(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
Меню поки порожнє. Загляньте пізніше 🦫
; + } + + return ( + <> + +
+ {visibleProducts.map((p) => ( + + ))} + {visibleProducts.length === 0 &&
У цій категорії поки немає страв
} +
+ + ); +} diff --git a/webapp/src/styles.css b/webapp/src/styles.css new file mode 100644 index 0000000..74bfd3c --- /dev/null +++ b/webapp/src/styles.css @@ -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); +} diff --git a/webapp/src/telegram.ts b/webapp/src/telegram.ts new file mode 100644 index 0000000..c9cb068 --- /dev/null +++ b/webapp/src/telegram.ts @@ -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; + 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(); +} diff --git a/webapp/src/types.ts b/webapp/src/types.ts new file mode 100644 index 0000000..cb40c26 --- /dev/null +++ b/webapp/src/types.ts @@ -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; +} diff --git a/webapp/src/vite-env.d.ts b/webapp/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/webapp/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json new file mode 100644 index 0000000..fdfae99 --- /dev/null +++ b/webapp/tsconfig.json @@ -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"] +} diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts new file mode 100644 index 0000000..75cee6b --- /dev/null +++ b/webapp/vite.config.ts @@ -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", + }, +});