Add admin panel, cart, checkout, and digital key delivery
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,10 @@ APP_NAME=Pitopn
|
||||
SECRET_KEY=change-me-to-a-long-random-string
|
||||
DEBUG=false
|
||||
|
||||
# Admin panel (/admin)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-strong-admin-password
|
||||
|
||||
# Database (PostgreSQL 17)
|
||||
POSTGRES_USER=pitopn
|
||||
POSTGRES_PASSWORD=change-me-strong-password
|
||||
|
||||
@@ -38,13 +38,34 @@ docker compose up --build -d
|
||||
|
||||
База `db` не публикуется наружу — только внутри сети Compose. Данные PostgreSQL хранятся в volume `postgres_data`.
|
||||
|
||||
## Админка
|
||||
|
||||
URL: `/admin`
|
||||
|
||||
Логин и пароль задаются в `.env`:
|
||||
|
||||
```env
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-strong-admin-password
|
||||
```
|
||||
|
||||
В админке: дашборд, товары, загрузка ключей, заказы.
|
||||
|
||||
## Магазин
|
||||
|
||||
- Каталог и карточка товара
|
||||
- Корзина (сессия)
|
||||
- Оформление с демо-оплатой и мгновенной выдачей кодов
|
||||
- Поиск заказа: `/lookup`
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
app/
|
||||
main.py # FastAPI + главная страница
|
||||
models.py # Product, Category
|
||||
seed.py # демо-товары при первом запуске
|
||||
main.py # FastAPI + сессии
|
||||
models.py # Product, ProductKey, Order
|
||||
routers/shop.py # витрина, корзина, заказы
|
||||
routers/admin.py # админка
|
||||
templates/ # Jinja2
|
||||
static/ # CSS/JS
|
||||
Dockerfile
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def _secure_equal(left: str, right: str) -> bool:
|
||||
left_digest = hashlib.sha256(left.encode("utf-8")).digest()
|
||||
right_digest = hashlib.sha256(right.encode("utf-8")).digest()
|
||||
return secrets.compare_digest(left_digest, right_digest)
|
||||
|
||||
|
||||
def is_admin(request: Request) -> bool:
|
||||
return bool(request.session.get("is_admin"))
|
||||
|
||||
|
||||
def login_admin(request: Request, username: str, password: str) -> bool:
|
||||
settings = get_settings()
|
||||
if _secure_equal(username, settings.admin_username) and _secure_equal(
|
||||
password, settings.admin_password
|
||||
):
|
||||
request.session["is_admin"] = True
|
||||
request.session["admin_user"] = username
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def logout_admin(request: Request) -> None:
|
||||
request.session.pop("is_admin", None)
|
||||
request.session.pop("admin_user", None)
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Product, ProductKey
|
||||
|
||||
|
||||
def get_cart_map(request: Request) -> dict[str, int]:
|
||||
raw = request.session.get("cart") or {}
|
||||
cart: dict[str, int] = {}
|
||||
for key, value in raw.items():
|
||||
try:
|
||||
qty = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if qty > 0:
|
||||
cart[str(key)] = qty
|
||||
return cart
|
||||
|
||||
|
||||
def save_cart(request: Request, cart: dict[str, int]) -> None:
|
||||
request.session["cart"] = {str(k): int(v) for k, v in cart.items() if int(v) > 0}
|
||||
|
||||
|
||||
def cart_count(request: Request) -> int:
|
||||
return sum(get_cart_map(request).values())
|
||||
|
||||
|
||||
def available_stock(db: Session, product_id: int) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(ProductKey)
|
||||
.where(ProductKey.product_id == product_id, ProductKey.is_sold.is_(False))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def build_cart_items(db: Session, request: Request) -> tuple[list[dict], Decimal]:
|
||||
cart = get_cart_map(request)
|
||||
items: list[dict] = []
|
||||
total = Decimal("0.00")
|
||||
if not cart:
|
||||
return items, total
|
||||
|
||||
product_ids = [int(pid) for pid in cart.keys()]
|
||||
products = {
|
||||
p.id: p
|
||||
for p in db.scalars(select(Product).where(Product.id.in_(product_ids))).all()
|
||||
}
|
||||
|
||||
clean_cart = dict(cart)
|
||||
for pid_str, qty in cart.items():
|
||||
product = products.get(int(pid_str))
|
||||
if not product or not product.is_active:
|
||||
clean_cart.pop(pid_str, None)
|
||||
continue
|
||||
stock = available_stock(db, product.id)
|
||||
qty = min(qty, max(stock, 0))
|
||||
if qty <= 0:
|
||||
clean_cart.pop(pid_str, None)
|
||||
continue
|
||||
clean_cart[pid_str] = qty
|
||||
line_total = product.price * qty
|
||||
total += line_total
|
||||
items.append(
|
||||
{
|
||||
"product": product,
|
||||
"quantity": qty,
|
||||
"stock": stock,
|
||||
"line_total": line_total,
|
||||
}
|
||||
)
|
||||
|
||||
if clean_cart != cart:
|
||||
save_cart(request, clean_cart)
|
||||
return items, total
|
||||
@@ -10,6 +10,9 @@ class Settings(BaseSettings):
|
||||
secret_key: str = "dev-secret"
|
||||
debug: bool = False
|
||||
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "admin123"
|
||||
|
||||
postgres_user: str = "pitopn"
|
||||
postgres_password: str = "pitopn"
|
||||
postgres_db: str = "pitopn"
|
||||
|
||||
+18
-36
@@ -2,17 +2,16 @@ import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import Base, engine, get_db
|
||||
from app.models import Category, Product
|
||||
from app.database import Base, engine
|
||||
from app.routers import admin as admin_router
|
||||
from app.routers import shop as shop_router
|
||||
from app.seed import seed_if_empty
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
@@ -46,7 +45,15 @@ async def lifespan(_: FastAPI):
|
||||
|
||||
|
||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_key,
|
||||
session_cookie="pitopn_session",
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
)
|
||||
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
||||
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
|
||||
@@ -61,37 +68,12 @@ def format_price(value) -> str:
|
||||
|
||||
templates.env.filters["rub"] = format_price
|
||||
|
||||
shop_router.setup(templates)
|
||||
admin_router.setup(templates)
|
||||
app.include_router(shop_router.router)
|
||||
app.include_router(admin_router.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def home(request: Request, db: Session = Depends(get_db)):
|
||||
categories = db.scalars(
|
||||
select(Category).order_by(Category.sort_order, Category.name)
|
||||
).all()
|
||||
featured = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.is_active.is_(True), Product.is_featured.is_(True))
|
||||
.order_by(Product.id)
|
||||
.limit(4)
|
||||
).all()
|
||||
products = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.is_active.is_(True))
|
||||
.order_by(Product.is_featured.desc(), Product.id)
|
||||
.limit(12)
|
||||
).all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
{
|
||||
"app_name": settings.app_name,
|
||||
"categories": categories,
|
||||
"featured": featured,
|
||||
"products": products,
|
||||
},
|
||||
)
|
||||
|
||||
+66
-2
@@ -1,8 +1,17 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Numeric, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
@@ -35,3 +44,58 @@ class Product(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
keys: Mapped[list["ProductKey"]] = relationship(back_populates="product")
|
||||
|
||||
|
||||
class ProductKey(Base):
|
||||
__tablename__ = "product_keys"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True)
|
||||
code: Mapped[str] = mapped_column(Text)
|
||||
is_sold: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
order_id: Mapped[int | None] = mapped_column(ForeignKey("orders.id"), nullable=True)
|
||||
sold_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
product: Mapped["Product"] = relationship(back_populates="keys")
|
||||
order: Mapped["Order | None"] = relationship(back_populates="keys")
|
||||
|
||||
|
||||
class Order(Base):
|
||||
__tablename__ = "orders"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
public_id: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("0.00"))
|
||||
customer_note: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
items: Mapped[list["OrderItem"]] = relationship(back_populates="order")
|
||||
keys: Mapped[list["ProductKey"]] = relationship(back_populates="order")
|
||||
|
||||
|
||||
class OrderItem(Base):
|
||||
__tablename__ = "order_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
|
||||
quantity: Mapped[int] = mapped_column(Integer, default=1)
|
||||
delivered_codes: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
order: Mapped["Order"] = relationship(back_populates="items")
|
||||
product: Mapped["Product"] = relationship()
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.cart import available_stock
|
||||
from app.models import Order, OrderItem, Product, ProductKey
|
||||
|
||||
|
||||
class OrderError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def make_public_id() -> str:
|
||||
return secrets.token_hex(4).upper()
|
||||
|
||||
|
||||
def create_order_from_cart(
|
||||
db: Session,
|
||||
*,
|
||||
email: str,
|
||||
cart: dict[str, int],
|
||||
customer_note: str = "",
|
||||
) -> Order:
|
||||
if not cart:
|
||||
raise OrderError("Корзина пуста")
|
||||
|
||||
email = email.strip().lower()
|
||||
if "@" not in email or "." not in email.split("@")[-1]:
|
||||
raise OrderError("Укажите корректный email")
|
||||
|
||||
product_ids = [int(pid) for pid in cart.keys()]
|
||||
products = {
|
||||
p.id: p
|
||||
for p in db.scalars(
|
||||
select(Product).where(Product.id.in_(product_ids), Product.is_active.is_(True))
|
||||
).all()
|
||||
}
|
||||
|
||||
order = Order(
|
||||
public_id=make_public_id(),
|
||||
email=email,
|
||||
status="pending",
|
||||
total=Decimal("0.00"),
|
||||
customer_note=customer_note.strip()[:500],
|
||||
)
|
||||
db.add(order)
|
||||
db.flush()
|
||||
|
||||
total = Decimal("0.00")
|
||||
for pid_str, qty in cart.items():
|
||||
product = products.get(int(pid_str))
|
||||
if not product:
|
||||
raise OrderError("Товар недоступен")
|
||||
qty = int(qty)
|
||||
if qty <= 0:
|
||||
continue
|
||||
stock = available_stock(db, product.id)
|
||||
if stock < qty:
|
||||
raise OrderError(
|
||||
f"Недостаточно ключей для «{product.title}» (в наличии {stock})"
|
||||
)
|
||||
line = product.price * qty
|
||||
total += line
|
||||
db.add(
|
||||
OrderItem(
|
||||
order_id=order.id,
|
||||
product_id=product.id,
|
||||
title=product.title,
|
||||
unit_price=product.price,
|
||||
quantity=qty,
|
||||
)
|
||||
)
|
||||
|
||||
if total <= 0:
|
||||
raise OrderError("Нечего оформлять")
|
||||
|
||||
order.total = total
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
def pay_and_deliver(db: Session, order: Order) -> Order:
|
||||
"""Demo payment: mark paid and assign unused digital keys."""
|
||||
if order.status == "delivered":
|
||||
return order
|
||||
if order.status == "cancelled":
|
||||
raise OrderError("Заказ отменён")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
items = db.scalars(select(OrderItem).where(OrderItem.order_id == order.id)).all()
|
||||
for item in items:
|
||||
keys = db.scalars(
|
||||
select(ProductKey)
|
||||
.where(
|
||||
ProductKey.product_id == item.product_id,
|
||||
ProductKey.is_sold.is_(False),
|
||||
)
|
||||
.order_by(ProductKey.id)
|
||||
.limit(item.quantity)
|
||||
.with_for_update()
|
||||
).all()
|
||||
if len(keys) < item.quantity:
|
||||
raise OrderError(f"Не хватает ключей для «{item.title}»")
|
||||
|
||||
codes: list[str] = []
|
||||
for key in keys:
|
||||
key.is_sold = True
|
||||
key.order_id = order.id
|
||||
key.sold_at = now
|
||||
codes.append(key.code)
|
||||
item.delivered_codes = "\n".join(codes)
|
||||
|
||||
order.status = "delivered"
|
||||
order.paid_at = now
|
||||
order.delivered_at = now
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
@@ -0,0 +1,414 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import is_admin, login_admin, logout_admin
|
||||
from app.cart import available_stock
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import Category, Order, Product, ProductKey
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def setup(templates: Jinja2Templates) -> None:
|
||||
router.templates = templates # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def admin_ctx(request: Request, **kwargs):
|
||||
data = {
|
||||
"request": request,
|
||||
"app_name": settings.app_name,
|
||||
"admin_user": request.session.get("admin_user", "admin"),
|
||||
}
|
||||
data.update(kwargs)
|
||||
return data
|
||||
|
||||
|
||||
def guard(request: Request):
|
||||
if not is_admin(request):
|
||||
return RedirectResponse("/admin/login", status_code=303)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def admin_login_page(request: Request):
|
||||
if is_admin(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/login.html",
|
||||
admin_ctx(request, error=None),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_class=HTMLResponse)
|
||||
def admin_login_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
if login_admin(request, username.strip(), password):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/login.html",
|
||||
admin_ctx(request, error="Неверный логин или пароль"),
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def admin_logout(request: Request):
|
||||
logout_admin(request)
|
||||
return RedirectResponse("/admin/login", status_code=303)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
if (redir := guard(request)):
|
||||
return redir
|
||||
|
||||
products_count = db.scalar(select(func.count()).select_from(Product)) or 0
|
||||
keys_free = (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(ProductKey)
|
||||
.where(ProductKey.is_sold.is_(False))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
orders_count = db.scalar(select(func.count()).select_from(Order)) or 0
|
||||
revenue = (
|
||||
db.scalar(
|
||||
select(func.coalesce(func.sum(Order.total), 0)).where(
|
||||
Order.status == "delivered"
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
recent_orders = db.scalars(select(Order).order_by(Order.id.desc()).limit(8)).all()
|
||||
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/dashboard.html",
|
||||
admin_ctx(
|
||||
request,
|
||||
products_count=products_count,
|
||||
keys_free=keys_free,
|
||||
orders_count=orders_count,
|
||||
revenue=revenue,
|
||||
recent_orders=recent_orders,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/products", response_class=HTMLResponse)
|
||||
def products_list(request: Request, db: Session = Depends(get_db)):
|
||||
if (redir := guard(request)):
|
||||
return redir
|
||||
products = db.scalars(select(Product).order_by(Product.id.desc())).all()
|
||||
stock_map = {p.id: available_stock(db, p.id) for p in products}
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/products.html",
|
||||
admin_ctx(request, products=products, stock_map=stock_map),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/products/new", response_class=HTMLResponse)
|
||||
def product_new(request: Request, db: Session = Depends(get_db)):
|
||||
if (redir := guard(request)):
|
||||
return redir
|
||||
categories = db.scalars(select(Category).order_by(Category.sort_order)).all()
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/product_form.html",
|
||||
admin_ctx(
|
||||
request,
|
||||
product=None,
|
||||
categories=categories,
|
||||
error=None,
|
||||
form={},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/products/new", response_class=HTMLResponse)
|
||||
def product_create(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
title: str = Form(...),
|
||||
slug: str = Form(...),
|
||||
short_description: str = Form(...),
|
||||
description: str = Form(""),
|
||||
price: str = Form(...),
|
||||
category_slug: str = Form(...),
|
||||
image_gradient: str = Form("mint"),
|
||||
delivery_note: str = Form("Мгновенная выдача"),
|
||||
is_featured: str | None = Form(None),
|
||||
is_active: str | None = Form(None),
|
||||
):
|
||||
if (redir := guard(request)):
|
||||
return redir
|
||||
categories = db.scalars(select(Category).order_by(Category.sort_order)).all()
|
||||
form = {
|
||||
"title": title,
|
||||
"slug": slug,
|
||||
"short_description": short_description,
|
||||
"description": description,
|
||||
"price": price,
|
||||
"category_slug": category_slug,
|
||||
"image_gradient": image_gradient,
|
||||
"delivery_note": delivery_note,
|
||||
"is_featured": bool(is_featured),
|
||||
"is_active": bool(is_active) if is_active is not None else True,
|
||||
}
|
||||
try:
|
||||
price_val = Decimal(price.replace(",", ".").replace(" ", ""))
|
||||
except (InvalidOperation, AttributeError):
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/product_form.html",
|
||||
admin_ctx(
|
||||
request,
|
||||
product=None,
|
||||
categories=categories,
|
||||
error="Некорректная цена",
|
||||
form=form,
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
slug_clean = slug.strip().lower().replace(" ", "-")
|
||||
exists = db.scalar(select(Product.id).where(Product.slug == slug_clean))
|
||||
if exists:
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/product_form.html",
|
||||
admin_ctx(
|
||||
request,
|
||||
product=None,
|
||||
categories=categories,
|
||||
error="Slug уже занят",
|
||||
form=form,
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
product = Product(
|
||||
title=title.strip(),
|
||||
slug=slug_clean,
|
||||
short_description=short_description.strip()[:300],
|
||||
description=description.strip(),
|
||||
price=price_val,
|
||||
category_slug=category_slug,
|
||||
image_gradient=image_gradient,
|
||||
delivery_note=delivery_note.strip()[:200],
|
||||
is_featured=bool(is_featured),
|
||||
is_active=bool(is_active),
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
return RedirectResponse(f"/admin/products/{product.id}/keys", status_code=303)
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/edit", response_class=HTMLResponse)
|
||||
def product_edit(product_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
if (redir := guard(request)):
|
||||
return redir
|
||||
product = db.get(Product, product_id)
|
||||
if not product:
|
||||
return RedirectResponse("/admin/products", status_code=303)
|
||||
categories = db.scalars(select(Category).order_by(Category.sort_order)).all()
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/product_form.html",
|
||||
admin_ctx(
|
||||
request,
|
||||
product=product,
|
||||
categories=categories,
|
||||
error=None,
|
||||
form={},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/edit", response_class=HTMLResponse)
|
||||
def product_update(
|
||||
product_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
title: str = Form(...),
|
||||
slug: str = Form(...),
|
||||
short_description: str = Form(...),
|
||||
description: str = Form(""),
|
||||
price: str = Form(...),
|
||||
category_slug: str = Form(...),
|
||||
image_gradient: str = Form("mint"),
|
||||
delivery_note: str = Form("Мгновенная выдача"),
|
||||
is_featured: str | None = Form(None),
|
||||
is_active: str | None = Form(None),
|
||||
):
|
||||
if (redir := guard(request)):
|
||||
return redir
|
||||
product = db.get(Product, product_id)
|
||||
if not product:
|
||||
return RedirectResponse("/admin/products", status_code=303)
|
||||
categories = db.scalars(select(Category).order_by(Category.sort_order)).all()
|
||||
|
||||
try:
|
||||
price_val = Decimal(price.replace(",", ".").replace(" ", ""))
|
||||
except (InvalidOperation, AttributeError):
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/product_form.html",
|
||||
admin_ctx(
|
||||
request,
|
||||
product=product,
|
||||
categories=categories,
|
||||
error="Некорректная цена",
|
||||
form={},
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
slug_clean = slug.strip().lower().replace(" ", "-")
|
||||
clash = db.scalar(
|
||||
select(Product.id).where(Product.slug == slug_clean, Product.id != product.id)
|
||||
)
|
||||
if clash:
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/product_form.html",
|
||||
admin_ctx(
|
||||
request,
|
||||
product=product,
|
||||
categories=categories,
|
||||
error="Slug уже занят",
|
||||
form={},
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
product.title = title.strip()
|
||||
product.slug = slug_clean
|
||||
product.short_description = short_description.strip()[:300]
|
||||
product.description = description.strip()
|
||||
product.price = price_val
|
||||
product.category_slug = category_slug
|
||||
product.image_gradient = image_gradient
|
||||
product.delivery_note = delivery_note.strip()[:200]
|
||||
product.is_featured = bool(is_featured)
|
||||
product.is_active = bool(is_active)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/products", status_code=303)
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/delete")
|
||||
def product_delete(product_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
if (redir := guard(request)):
|
||||
return redir
|
||||
product = db.get(Product, product_id)
|
||||
if product:
|
||||
db.execute(delete(ProductKey).where(ProductKey.product_id == product_id))
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/products", status_code=303)
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/keys", response_class=HTMLResponse)
|
||||
def product_keys(product_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
if (redir := guard(request)):
|
||||
return redir
|
||||
product = db.get(Product, product_id)
|
||||
if not product:
|
||||
return RedirectResponse("/admin/products", status_code=303)
|
||||
keys = db.scalars(
|
||||
select(ProductKey)
|
||||
.where(ProductKey.product_id == product_id)
|
||||
.order_by(ProductKey.is_sold, ProductKey.id.desc())
|
||||
).all()
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/keys.html",
|
||||
admin_ctx(
|
||||
request,
|
||||
product=product,
|
||||
keys=keys,
|
||||
stock=available_stock(db, product_id),
|
||||
message=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/keys", response_class=HTMLResponse)
|
||||
def product_keys_add(
|
||||
product_id: int,
|
||||
request: Request,
|
||||
codes: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if (redir := guard(request)):
|
||||
return redir
|
||||
product = db.get(Product, product_id)
|
||||
if not product:
|
||||
return RedirectResponse("/admin/products", status_code=303)
|
||||
|
||||
lines = [line.strip() for line in codes.splitlines() if line.strip()]
|
||||
added = 0
|
||||
for code in lines:
|
||||
db.add(ProductKey(product_id=product_id, code=code))
|
||||
added += 1
|
||||
db.commit()
|
||||
|
||||
keys = db.scalars(
|
||||
select(ProductKey)
|
||||
.where(ProductKey.product_id == product_id)
|
||||
.order_by(ProductKey.is_sold, ProductKey.id.desc())
|
||||
).all()
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/keys.html",
|
||||
admin_ctx(
|
||||
request,
|
||||
product=product,
|
||||
keys=keys,
|
||||
stock=available_stock(db, product_id),
|
||||
message=f"Добавлено ключей: {added}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/orders", response_class=HTMLResponse)
|
||||
def orders_list(request: Request, db: Session = Depends(get_db)):
|
||||
if (redir := guard(request)):
|
||||
return redir
|
||||
orders = db.scalars(select(Order).order_by(Order.id.desc()).limit(100)).all()
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/orders.html",
|
||||
admin_ctx(request, orders=orders),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/orders/{order_id}", response_class=HTMLResponse)
|
||||
def order_detail(order_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
if (redir := guard(request)):
|
||||
return redir
|
||||
order = db.get(Order, order_id)
|
||||
if not order:
|
||||
return RedirectResponse("/admin/orders", status_code=303)
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"admin/order_detail.html",
|
||||
admin_ctx(request, order=order),
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.cart import (
|
||||
available_stock,
|
||||
build_cart_items,
|
||||
cart_count,
|
||||
get_cart_map,
|
||||
save_cart,
|
||||
)
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import Category, Order, Product
|
||||
from app.orders import OrderError, create_order_from_cart, pay_and_deliver
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def setup(templates: Jinja2Templates) -> None:
|
||||
router.templates = templates # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def ctx(request: Request, **kwargs):
|
||||
data = {
|
||||
"request": request,
|
||||
"app_name": settings.app_name,
|
||||
"cart_count": cart_count(request),
|
||||
}
|
||||
data.update(kwargs)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def home(request: Request, db: Session = Depends(get_db)):
|
||||
categories = db.scalars(
|
||||
select(Category).order_by(Category.sort_order, Category.name)
|
||||
).all()
|
||||
featured = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.is_active.is_(True), Product.is_featured.is_(True))
|
||||
.order_by(Product.id)
|
||||
.limit(4)
|
||||
).all()
|
||||
products = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.is_active.is_(True))
|
||||
.order_by(Product.is_featured.desc(), Product.id)
|
||||
.limit(12)
|
||||
).all()
|
||||
stock_map = {
|
||||
p.id: available_stock(db, p.id) for p in [*featured, *products]
|
||||
}
|
||||
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"index.html",
|
||||
ctx(
|
||||
request,
|
||||
categories=categories,
|
||||
featured=featured,
|
||||
products=products,
|
||||
stock_map=stock_map,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/product/{slug}", response_class=HTMLResponse)
|
||||
def product_page(slug: str, request: Request, db: Session = Depends(get_db)):
|
||||
product = db.scalar(
|
||||
select(Product).where(Product.slug == slug, Product.is_active.is_(True))
|
||||
)
|
||||
if not product:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
stock = available_stock(db, product.id)
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"product.html",
|
||||
ctx(request, product=product, stock=stock),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cart/add/{product_id}")
|
||||
def cart_add(
|
||||
product_id: int,
|
||||
request: Request,
|
||||
quantity: int = Form(1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
product = db.scalar(
|
||||
select(Product).where(Product.id == product_id, Product.is_active.is_(True))
|
||||
)
|
||||
if not product:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
qty = max(1, min(int(quantity or 1), 10))
|
||||
stock = available_stock(db, product.id)
|
||||
cart = get_cart_map(request)
|
||||
current = cart.get(str(product_id), 0)
|
||||
cart[str(product_id)] = min(current + qty, max(stock, 0))
|
||||
if cart[str(product_id)] <= 0:
|
||||
cart.pop(str(product_id), None)
|
||||
save_cart(request, cart)
|
||||
return RedirectResponse("/cart", status_code=303)
|
||||
|
||||
|
||||
@router.get("/cart", response_class=HTMLResponse)
|
||||
def cart_page(request: Request, db: Session = Depends(get_db)):
|
||||
items, total = build_cart_items(db, request)
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"cart.html",
|
||||
ctx(request, items=items, total=total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cart/update")
|
||||
def cart_update(
|
||||
request: Request,
|
||||
product_id: int = Form(...),
|
||||
quantity: int = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
cart = get_cart_map(request)
|
||||
pid = str(product_id)
|
||||
qty = int(quantity)
|
||||
if qty <= 0:
|
||||
cart.pop(pid, None)
|
||||
else:
|
||||
stock = available_stock(db, product_id)
|
||||
cart[pid] = min(qty, max(stock, 0))
|
||||
if cart[pid] <= 0:
|
||||
cart.pop(pid, None)
|
||||
save_cart(request, cart)
|
||||
return RedirectResponse("/cart", status_code=303)
|
||||
|
||||
|
||||
@router.post("/cart/remove/{product_id}")
|
||||
def cart_remove(product_id: int, request: Request):
|
||||
cart = get_cart_map(request)
|
||||
cart.pop(str(product_id), None)
|
||||
save_cart(request, cart)
|
||||
return RedirectResponse("/cart", status_code=303)
|
||||
|
||||
|
||||
@router.get("/checkout", response_class=HTMLResponse)
|
||||
def checkout_page(request: Request, db: Session = Depends(get_db)):
|
||||
items, total = build_cart_items(db, request)
|
||||
if not items:
|
||||
return RedirectResponse("/cart", status_code=303)
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"checkout.html",
|
||||
ctx(request, items=items, total=total, error=None),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/checkout", response_class=HTMLResponse)
|
||||
def checkout_submit(
|
||||
request: Request,
|
||||
email: str = Form(...),
|
||||
note: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
items, total = build_cart_items(db, request)
|
||||
if not items:
|
||||
return RedirectResponse("/cart", status_code=303)
|
||||
|
||||
cart = get_cart_map(request)
|
||||
try:
|
||||
order = create_order_from_cart(
|
||||
db, email=email, cart=cart, customer_note=note
|
||||
)
|
||||
order = pay_and_deliver(db, order)
|
||||
save_cart(request, {})
|
||||
return RedirectResponse(f"/order/{order.public_id}?email={order.email}", status_code=303)
|
||||
except OrderError as exc:
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"checkout.html",
|
||||
ctx(request, items=items, total=total, error=str(exc)),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/order/{public_id}", response_class=HTMLResponse)
|
||||
def order_page(
|
||||
public_id: str,
|
||||
request: Request,
|
||||
email: str = "",
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
order = db.scalar(select(Order).where(Order.public_id == public_id.upper()))
|
||||
if not order:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
if email and email.strip().lower() != order.email:
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"order_locked.html",
|
||||
ctx(request, public_id=public_id, error="Email не совпадает с заказом"),
|
||||
)
|
||||
if not email:
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"order_locked.html",
|
||||
ctx(request, public_id=public_id, error=None),
|
||||
)
|
||||
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"order.html",
|
||||
ctx(request, order=order),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/order/{public_id}", response_class=HTMLResponse)
|
||||
def order_unlock(
|
||||
public_id: str,
|
||||
request: Request,
|
||||
email: str = Form(...),
|
||||
):
|
||||
return RedirectResponse(
|
||||
f"/order/{public_id}?email={email.strip().lower()}",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/lookup", response_class=HTMLResponse)
|
||||
def lookup_page(request: Request):
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"lookup.html",
|
||||
ctx(request, error=None),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/lookup")
|
||||
def lookup_submit(
|
||||
request: Request,
|
||||
public_id: str = Form(...),
|
||||
email: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
order = db.scalar(select(Order).where(Order.public_id == public_id.strip().upper()))
|
||||
if not order or order.email != email.strip().lower():
|
||||
return router.templates.TemplateResponse( # type: ignore[attr-defined]
|
||||
request,
|
||||
"lookup.html",
|
||||
ctx(request, error="Заказ не найден. Проверьте номер и email."),
|
||||
status_code=404,
|
||||
)
|
||||
return RedirectResponse(
|
||||
f"/order/{order.public_id}?email={order.email}",
|
||||
status_code=303,
|
||||
)
|
||||
+40
-7
@@ -1,9 +1,10 @@
|
||||
from decimal import Decimal
|
||||
import secrets
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Category, Product
|
||||
from app.models import Category, Product, ProductKey
|
||||
|
||||
|
||||
CATEGORIES = [
|
||||
@@ -44,7 +45,7 @@ PRODUCTS = [
|
||||
"category_slug": "giftcards",
|
||||
"image_gradient": "mint",
|
||||
"is_featured": True,
|
||||
"delivery_note": "Код на email за 1–3 минуты",
|
||||
"delivery_note": "Код сразу после оплаты",
|
||||
},
|
||||
{
|
||||
"slug": "adobe-cc-1m",
|
||||
@@ -55,7 +56,7 @@ PRODUCTS = [
|
||||
"category_slug": "software",
|
||||
"image_gradient": "coral",
|
||||
"is_featured": True,
|
||||
"delivery_note": "Доступ в личном кабинете",
|
||||
"delivery_note": "Доступ сразу после оплаты",
|
||||
},
|
||||
{
|
||||
"slug": "cyberpunk-steam",
|
||||
@@ -77,7 +78,7 @@ PRODUCTS = [
|
||||
"category_slug": "accounts",
|
||||
"image_gradient": "sand",
|
||||
"is_featured": True,
|
||||
"delivery_note": "Активация до 15 минут",
|
||||
"delivery_note": "Данные сразу после оплаты",
|
||||
},
|
||||
{
|
||||
"slug": "windows-11-pro",
|
||||
@@ -88,7 +89,7 @@ PRODUCTS = [
|
||||
"category_slug": "software",
|
||||
"image_gradient": "mint",
|
||||
"is_featured": False,
|
||||
"delivery_note": "Ключ на email",
|
||||
"delivery_note": "Ключ сразу после оплаты",
|
||||
},
|
||||
{
|
||||
"slug": "playstation-1500",
|
||||
@@ -99,11 +100,15 @@ PRODUCTS = [
|
||||
"category_slug": "giftcards",
|
||||
"image_gradient": "coral",
|
||||
"is_featured": False,
|
||||
"delivery_note": "Код за несколько минут",
|
||||
"delivery_note": "Код сразу после оплаты",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _demo_code(prefix: str) -> str:
|
||||
return f"{prefix}-{secrets.token_hex(4).upper()}-{secrets.token_hex(3).upper()}"
|
||||
|
||||
|
||||
def seed_if_empty(db: Session) -> None:
|
||||
has_categories = db.scalar(select(Category.id).limit(1)) is not None
|
||||
if not has_categories:
|
||||
@@ -114,5 +119,33 @@ def seed_if_empty(db: Session) -> None:
|
||||
has_products = db.scalar(select(Product.id).limit(1)) is not None
|
||||
if not has_products:
|
||||
for item in PRODUCTS:
|
||||
db.add(Product(**item))
|
||||
product = Product(**item)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
for _ in range(8):
|
||||
db.add(
|
||||
ProductKey(
|
||||
product_id=product.id,
|
||||
code=_demo_code(product.slug[:8].upper()),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
else:
|
||||
# Ensure demo stock exists for products without keys
|
||||
products = db.scalars(select(Product)).all()
|
||||
for product in products:
|
||||
has_keys = (
|
||||
db.scalar(
|
||||
select(ProductKey.id).where(ProductKey.product_id == product.id).limit(1)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
if not has_keys:
|
||||
for _ in range(5):
|
||||
db.add(
|
||||
ProductKey(
|
||||
product_id=product.id,
|
||||
code=_demo_code(product.slug[:8].upper()),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
+334
-18
@@ -87,6 +87,21 @@ main,
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.cart-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
margin-left: 0.25rem;
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-hot);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero {
|
||||
min-height: calc(100svh - 72px);
|
||||
display: grid;
|
||||
@@ -154,6 +169,12 @@ main,
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--ink);
|
||||
color: #f4faf6;
|
||||
@@ -172,6 +193,11 @@ main,
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-tiny {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
position: relative;
|
||||
min-height: min(62vh, 560px);
|
||||
@@ -181,7 +207,6 @@ main,
|
||||
.hero-plane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 0;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(15, 122, 76, 0.85), rgba(18, 32, 26, 0.92)),
|
||||
repeating-linear-gradient(
|
||||
@@ -216,17 +241,23 @@ main,
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
section {
|
||||
section,
|
||||
.page-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.5rem, 6vw, 5rem) clamp(1.2rem, 4vw, 3rem);
|
||||
}
|
||||
|
||||
.page-section.narrow {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
.section-head h2,
|
||||
.section-head h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.8rem, 3vw, 2.4rem);
|
||||
@@ -296,10 +327,15 @@ section {
|
||||
}
|
||||
|
||||
.product-media {
|
||||
display: block;
|
||||
min-height: 140px;
|
||||
background: linear-gradient(135deg, #0f7a4c, #1b4332);
|
||||
}
|
||||
|
||||
.product-media.tall {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.gradient-mint .product-media {
|
||||
background: linear-gradient(135deg, #2d6a4f, #95d5b2);
|
||||
}
|
||||
@@ -331,6 +367,11 @@ section {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.product-body h3 a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.product-body p {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
@@ -360,6 +401,10 @@ section {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.price.big {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -416,20 +461,6 @@ section {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1.4rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
padding: 0.85rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
background: var(--ink);
|
||||
color: #f4faf6;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
@@ -446,6 +477,280 @@ section {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 1.2rem;
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.product-info h1 {
|
||||
margin: 0 0 0.8rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 1.05rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.stock-line {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.buy-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qty-label {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
font: inherit;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.inline-form input[type="number"] {
|
||||
width: 4.5rem;
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stack-form label {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stack-form .check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stack-form .check input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
text-align: left;
|
||||
padding: 0.85rem 0.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cart-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.checkout-list {
|
||||
list-style: none;
|
||||
margin: 0 0 1.2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.checkout-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flash.error {
|
||||
background: rgba(232, 93, 4, 0.12);
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.flash.ok {
|
||||
background: rgba(15, 122, 76, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.order-item {
|
||||
margin: 1.2rem 0;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.codes pre,
|
||||
pre.codes {
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 12px;
|
||||
background: #12201a;
|
||||
color: #e8efe9;
|
||||
overflow-x: auto;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.admin-body {
|
||||
background:
|
||||
radial-gradient(900px 500px at 0% 0%, #c8e6d3 0%, transparent 50%),
|
||||
linear-gradient(165deg, #e8efe9, #d5e2d8);
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding: 1.4rem 1.1rem;
|
||||
border-right: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.admin-nav a {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
padding: 1.5rem clamp(1rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.admin-login {
|
||||
margin-top: 12vh;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 1rem;
|
||||
border-top: 2px solid var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.row-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-head.compact {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions a,
|
||||
.linkish {
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logout-form {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -473,8 +778,14 @@ section {
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.hero {
|
||||
.hero,
|
||||
.product-layout,
|
||||
.admin-shell,
|
||||
.admin-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
@@ -489,6 +800,11 @@ section {
|
||||
.steps {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -1,24 +1,4 @@
|
||||
(() => {
|
||||
const toast = document.getElementById("toast");
|
||||
let toastTimer;
|
||||
|
||||
const showToast = (message) => {
|
||||
if (!toast) return;
|
||||
toast.hidden = false;
|
||||
toast.textContent = message;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {
|
||||
toast.hidden = true;
|
||||
}, 2600);
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-buy]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const slug = button.getAttribute("data-buy");
|
||||
showToast(`«${slug}» — оплата скоро будет подключена`);
|
||||
});
|
||||
});
|
||||
|
||||
const reveals = document.querySelectorAll(".reveal");
|
||||
if ("IntersectionObserver" in window) {
|
||||
const observer = new IntersectionObserver(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Админка — {{ app_name }}{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Syne:wght@500;700;800&family=Manrope:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-nav">
|
||||
<a class="brand" href="/admin">{{ app_name }} Admin</a>
|
||||
<a href="/admin">Дашборд</a>
|
||||
<a href="/admin/products">Товары</a>
|
||||
<a href="/admin/orders">Заказы</a>
|
||||
<a href="/" target="_blank">Открыть магазин</a>
|
||||
<form method="post" action="/admin/logout" class="logout-form">
|
||||
<button type="submit" class="btn btn-ghost btn-tiny">Выйти ({{ admin_user }})</button>
|
||||
</form>
|
||||
</aside>
|
||||
<div class="admin-main">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Дашборд — {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<h2>Дашборд</h2>
|
||||
<p>Сводка по магазину</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Товары</span>
|
||||
<strong>{{ products_count }}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Свободные ключи</span>
|
||||
<strong>{{ keys_free }}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Заказы</span>
|
||||
<strong>{{ orders_count }}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Выручка</span>
|
||||
<strong>{{ revenue | rub }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-head compact">
|
||||
<h3>Последние заказы</h3>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Сумма</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in recent_orders %}
|
||||
<tr>
|
||||
<td><a href="/admin/orders/{{ order.id }}">{{ order.public_id }}</a></td>
|
||||
<td>{{ order.email }}</td>
|
||||
<td>{{ order.total | rub }}</td>
|
||||
<td>{{ order.status }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4">Заказов пока нет</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Ключи — {{ product.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<h2>Ключи: {{ product.title }}</h2>
|
||||
<p>Свободно: {{ stock }} шт.</p>
|
||||
</div>
|
||||
|
||||
{% if message %}
|
||||
<p class="flash ok">{{ message }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="stack-form">
|
||||
<label>
|
||||
Добавить ключи (по одному на строку)
|
||||
<textarea name="codes" rows="8" required placeholder="XXXX-YYYY-ZZZZ"></textarea>
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">Загрузить</button>
|
||||
</form>
|
||||
|
||||
<div class="table-wrap" style="margin-top: 2rem">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Код</th>
|
||||
<th>Статус</th>
|
||||
<th>Order</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key in keys %}
|
||||
<tr>
|
||||
<td><code>{{ key.code }}</code></td>
|
||||
<td>{% if key.is_sold %}sold{% else %}free{% endif %}</td>
|
||||
<td>{% if key.order_id %}#{{ key.order_id }}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3">Ключей пока нет</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Вход в админку — {{ app_name }}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=Manrope:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<section class="page-section narrow admin-login">
|
||||
<h1>{{ app_name }} Admin</h1>
|
||||
<p class="muted">Вход для управления магазином</p>
|
||||
{% if error %}
|
||||
<p class="flash error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/login" class="stack-form">
|
||||
<label>
|
||||
Логин
|
||||
<input type="text" name="username" required autocomplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<input type="password" name="password" required autocomplete="current-password" />
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">Войти</button>
|
||||
</form>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Заказ {{ order.public_id }} — {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<h2>Заказ {{ order.public_id }}</h2>
|
||||
<p>{{ order.email }} · {{ order.status }} · {{ order.total | rub }}</p>
|
||||
</div>
|
||||
|
||||
{% if order.customer_note %}
|
||||
<p><strong>Комментарий:</strong> {{ order.customer_note }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% for item in order.items %}
|
||||
<article class="order-item">
|
||||
<h3>{{ item.title }} × {{ item.quantity }}</h3>
|
||||
<p class="muted">{{ item.unit_price | rub }}</p>
|
||||
{% if item.delivered_codes %}
|
||||
<pre class="codes">{{ item.delivered_codes }}</pre>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
<a class="btn btn-ghost" href="/admin/orders">← К списку</a>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Заказы — {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<h2>Заказы</h2>
|
||||
<p>Последние 100 заказов</p>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Номер</th>
|
||||
<th>Email</th>
|
||||
<th>Сумма</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td><a href="/admin/orders/{{ order.id }}">{{ order.public_id }}</a></td>
|
||||
<td>{{ order.email }}</td>
|
||||
<td>{{ order.total | rub }}</td>
|
||||
<td>{{ order.status }}</td>
|
||||
<td>{{ order.created_at }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Заказов нет</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}{% if product %}Редактирование{% else %}Новый товар{% endif %} — {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<h2>{% if product %}Редактировать товар{% else %}Новый товар{% endif %}</h2>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<p class="flash error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="stack-form admin-form">
|
||||
<label>
|
||||
Название
|
||||
<input name="title" required value="{{ form.title if form else product.title if product else '' }}" />
|
||||
</label>
|
||||
<label>
|
||||
Slug
|
||||
<input name="slug" required value="{{ form.slug if form else product.slug if product else '' }}" />
|
||||
</label>
|
||||
<label>
|
||||
Краткое описание
|
||||
<input name="short_description" required maxlength="300" value="{{ form.short_description if form else product.short_description if product else '' }}" />
|
||||
</label>
|
||||
<label>
|
||||
Описание
|
||||
<textarea name="description" rows="4">{{ form.description if form else product.description if product else '' }}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
Цена (₽)
|
||||
<input name="price" required value="{{ form.price if form else product.price if product else '' }}" />
|
||||
</label>
|
||||
<label>
|
||||
Категория
|
||||
<select name="category_slug" required>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.slug }}"
|
||||
{% if (form.category_slug if form else product.category_slug if product else '') == category.slug %}selected{% endif %}>
|
||||
{{ category.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Градиент карточки
|
||||
<select name="image_gradient">
|
||||
{% set g = form.image_gradient if form else product.image_gradient if product else 'mint' %}
|
||||
{% for opt in ['mint', 'coral', 'ink', 'sand'] %}
|
||||
<option value="{{ opt }}" {% if g == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Подпись доставки
|
||||
<input name="delivery_note" value="{{ form.delivery_note if form else product.delivery_note if product else 'Мгновенная выдача' }}" />
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="is_featured" value="1"
|
||||
{% if form %}{% if form.is_featured %}checked{% endif %}{% elif product and product.is_featured %}checked{% endif %} />
|
||||
В избранном
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="is_active" value="1"
|
||||
{% if form %}{% if form.is_active %}checked{% endif %}{% elif not product or product.is_active %}checked{% endif %} />
|
||||
Активен
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Товары — {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="section-head row-head">
|
||||
<div>
|
||||
<h2>Товары</h2>
|
||||
<p>Каталог и остатки ключей</p>
|
||||
</div>
|
||||
<a class="btn btn-primary" href="/admin/products/new">Добавить товар</a>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Цена</th>
|
||||
<th>Остаток</th>
|
||||
<th>Статус</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for product in products %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ product.title }}</strong>
|
||||
<div class="muted">{{ product.slug }}</div>
|
||||
</td>
|
||||
<td>{{ product.price | rub }}</td>
|
||||
<td>{{ stock_map.get(product.id, 0) }}</td>
|
||||
<td>{% if product.is_active %}active{% else %}hidden{% endif %}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/products/{{ product.id }}/edit">Изменить</a>
|
||||
<a href="/admin/products/{{ product.id }}/keys">Ключи</a>
|
||||
<form method="post" action="/admin/products/{{ product.id }}/delete" onsubmit="return confirm('Удалить товар?')">
|
||||
<button type="submit" class="linkish">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Товаров нет</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -21,11 +21,13 @@
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/">{{ app_name }}</a>
|
||||
<nav class="nav" aria-label="Основное меню">
|
||||
<a href="#catalog">Каталог</a>
|
||||
<a href="#how">Как купить</a>
|
||||
<a href="#support">Поддержка</a>
|
||||
<a href="/#catalog">Каталог</a>
|
||||
<a href="/lookup">Мой заказ</a>
|
||||
<a href="/#how">Как купить</a>
|
||||
</nav>
|
||||
<a class="header-cta" href="#catalog">В каталог</a>
|
||||
<a class="header-cta cart-link" href="/cart">
|
||||
Корзина{% if cart_count %} <span class="cart-badge">{{ cart_count }}</span>{% endif %}
|
||||
</a>
|
||||
</header>
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
<footer class="site-footer" id="support">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Корзина — {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-section">
|
||||
<div class="section-head">
|
||||
<h2>Корзина</h2>
|
||||
<p>Проверьте товары перед оформлением</p>
|
||||
</div>
|
||||
|
||||
{% if not items %}
|
||||
<p class="empty">Корзина пуста. <a href="/#catalog">Перейти в каталог</a></p>
|
||||
{% else %}
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Товар</th>
|
||||
<th>Цена</th>
|
||||
<th>Кол-во</th>
|
||||
<th>Сумма</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/product/{{ item.product.slug }}">{{ item.product.title }}</a>
|
||||
<div class="muted">В наличии: {{ item.stock }}</div>
|
||||
</td>
|
||||
<td>{{ item.product.price | rub }}</td>
|
||||
<td>
|
||||
<form method="post" action="/cart/update" class="inline-form">
|
||||
<input type="hidden" name="product_id" value="{{ item.product.id }}" />
|
||||
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}" />
|
||||
<button class="btn btn-ghost btn-tiny" type="submit">OK</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>{{ item.line_total | rub }}</td>
|
||||
<td>
|
||||
<form method="post" action="/cart/remove/{{ item.product.id }}">
|
||||
<button class="btn btn-ghost btn-tiny" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cart-summary">
|
||||
<div class="price big">Итого: {{ total | rub }}</div>
|
||||
<a class="btn btn-primary" href="/checkout">Оформить заказ</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Оформление — {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-section narrow">
|
||||
<div class="section-head">
|
||||
<h2>Оформление заказа</h2>
|
||||
<p>Демо-оплата: заказ сразу выдаёт цифровые коды</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<p class="flash error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<ul class="checkout-list">
|
||||
{% for item in items %}
|
||||
<li>
|
||||
<span>{{ item.product.title }} × {{ item.quantity }}</span>
|
||||
<strong>{{ item.line_total | rub }}</strong>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="price big">К оплате: {{ total | rub }}</p>
|
||||
|
||||
<form method="post" action="/checkout" class="stack-form">
|
||||
<label>
|
||||
Email для выдачи
|
||||
<input type="email" name="email" required placeholder="you@email.com" />
|
||||
</label>
|
||||
<label>
|
||||
Комментарий (необязательно)
|
||||
<textarea name="note" rows="3" placeholder="Например: регион аккаунта"></textarea>
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">Оплатить и получить коды</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
+20
-14
@@ -6,11 +6,11 @@
|
||||
<p class="brand-mark">{{ app_name }}</p>
|
||||
<h1>Цифровые товары без ожидания</h1>
|
||||
<p class="hero-lead">
|
||||
Ключи, софт и подарочные карты — оплатил и сразу получил доступ в личном кабинете.
|
||||
Ключи, софт и подарочные карты — оплатил и сразу получил код.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary" href="#catalog">Смотреть каталог</a>
|
||||
<a class="btn btn-ghost" href="#how">Как это работает</a>
|
||||
<a class="btn btn-ghost" href="/lookup">Найти заказ</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual" aria-hidden="true">
|
||||
@@ -46,14 +46,18 @@
|
||||
<div class="featured-rail" aria-label="Избранное">
|
||||
{% for product in featured %}
|
||||
<article class="product product-featured gradient-{{ product.image_gradient }} reveal">
|
||||
<div class="product-media"></div>
|
||||
<a class="product-media" href="/product/{{ product.slug }}"></a>
|
||||
<div class="product-body">
|
||||
<p class="product-tag">{{ product.delivery_note }}</p>
|
||||
<h3>{{ product.title }}</h3>
|
||||
<h3><a href="/product/{{ product.slug }}">{{ product.title }}</a></h3>
|
||||
<p>{{ product.short_description }}</p>
|
||||
<div class="product-meta">
|
||||
<span class="price">{{ product.price | rub }}</span>
|
||||
<button type="button" class="btn btn-small" data-buy="{{ product.slug }}">Купить</button>
|
||||
<form method="post" action="/cart/add/{{ product.id }}">
|
||||
<button type="submit" class="btn btn-small" {% if stock_map.get(product.id, 0) < 1 %}disabled{% endif %}>
|
||||
{% if stock_map.get(product.id, 0) < 1 %}Нет в наличии{% else %}В корзину{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -64,14 +68,18 @@
|
||||
<div class="product-grid">
|
||||
{% for product in products %}
|
||||
<article class="product gradient-{{ product.image_gradient }} reveal">
|
||||
<div class="product-media"></div>
|
||||
<a class="product-media" href="/product/{{ product.slug }}"></a>
|
||||
<div class="product-body">
|
||||
<p class="product-tag">{{ product.delivery_note }}</p>
|
||||
<h3>{{ product.title }}</h3>
|
||||
<h3><a href="/product/{{ product.slug }}">{{ product.title }}</a></h3>
|
||||
<p>{{ product.short_description }}</p>
|
||||
<div class="product-meta">
|
||||
<span class="price">{{ product.price | rub }}</span>
|
||||
<button type="button" class="btn btn-small" data-buy="{{ product.slug }}">Купить</button>
|
||||
<form method="post" action="/cart/add/{{ product.id }}">
|
||||
<button type="submit" class="btn btn-small" {% if stock_map.get(product.id, 0) < 1 %}disabled{% endif %}>
|
||||
{% if stock_map.get(product.id, 0) < 1 %}Нет в наличии{% else %}В корзину{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -90,20 +98,18 @@
|
||||
<li>
|
||||
<span class="step-num">01</span>
|
||||
<h3>Выберите товар</h3>
|
||||
<p>Ключ, подписка или карта пополнения из каталога.</p>
|
||||
<p>Добавьте ключ, подписку или карту в корзину.</p>
|
||||
</li>
|
||||
<li>
|
||||
<span class="step-num">02</span>
|
||||
<h3>Оплатите</h3>
|
||||
<p>Безопасная оплата картой или через доступные методы.</p>
|
||||
<h3>Оформите заказ</h3>
|
||||
<p>Укажите email — туда привязывается выдача.</p>
|
||||
</li>
|
||||
<li>
|
||||
<span class="step-num">03</span>
|
||||
<h3>Получите сразу</h3>
|
||||
<p>Код или доступ приходят автоматически после оплаты.</p>
|
||||
<p>Коды открываются на странице заказа мгновенно.</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Мой заказ — {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-section narrow">
|
||||
<div class="section-head">
|
||||
<h2>Найти заказ</h2>
|
||||
<p>Введите номер заказа и email</p>
|
||||
</div>
|
||||
{% if error %}
|
||||
<p class="flash error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/lookup" class="stack-form">
|
||||
<label>
|
||||
Номер заказа
|
||||
<input type="text" name="public_id" required placeholder="например A1B2C3D4" />
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" required />
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">Найти</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Заказ {{ order.public_id }} — {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-section narrow">
|
||||
<div class="section-head">
|
||||
<h2>Заказ {{ order.public_id }}</h2>
|
||||
<p>Статус: <strong>{{ order.status }}</strong> · {{ order.email }}</p>
|
||||
</div>
|
||||
|
||||
<p class="price big">Сумма: {{ order.total | rub }}</p>
|
||||
|
||||
{% for item in order.items %}
|
||||
<article class="order-item">
|
||||
<h3>{{ item.title }} × {{ item.quantity }}</h3>
|
||||
<p class="muted">{{ item.unit_price | rub }} за шт.</p>
|
||||
{% if item.delivered_codes %}
|
||||
<div class="codes">
|
||||
<p class="product-tag">Ваши коды</p>
|
||||
<pre>{{ item.delivered_codes }}</pre>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Коды ещё не выданы.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
<p class="muted">Сохраните номер заказа — его можно открыть позже в разделе «Мой заказ».</p>
|
||||
<a class="btn btn-ghost" href="/">На главную</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Открыть заказ — {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-section narrow">
|
||||
<div class="section-head">
|
||||
<h2>Доступ к заказу {{ public_id }}</h2>
|
||||
<p>Введите email, указанный при покупке</p>
|
||||
</div>
|
||||
{% if error %}
|
||||
<p class="flash error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/order/{{ public_id }}" class="stack-form">
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" required />
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">Открыть</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ product.title }} — {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-section product-page">
|
||||
<a class="back-link" href="/#catalog">← К каталогу</a>
|
||||
<div class="product-layout">
|
||||
<div class="product-hero-media gradient-{{ product.image_gradient }}">
|
||||
<div class="product-media tall"></div>
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<p class="product-tag">{{ product.delivery_note }}</p>
|
||||
<h1>{{ product.title }}</h1>
|
||||
<p class="lead">{{ product.short_description }}</p>
|
||||
<p>{{ product.description }}</p>
|
||||
<p class="stock-line">В наличии: {{ stock }} шт.</p>
|
||||
<div class="buy-row">
|
||||
<span class="price big">{{ product.price | rub }}</span>
|
||||
<form method="post" action="/cart/add/{{ product.id }}" class="inline-form">
|
||||
<label class="qty-label">
|
||||
Кол-во
|
||||
<input type="number" name="quantity" min="1" max="{{ stock if stock > 0 else 1 }}" value="1" {% if stock < 1 %}disabled{% endif %} />
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit" {% if stock < 1 %}disabled{% endif %}>
|
||||
{% if stock < 1 %}Нет в наличии{% else %}В корзину{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -27,6 +27,8 @@ services:
|
||||
APP_NAME: ${APP_NAME:-Pitopn}
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-to-a-long-random-string}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-strong-admin-password}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-pitopn}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-strong-password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-pitopn}
|
||||
|
||||
+2
-1
@@ -5,4 +5,5 @@ psycopg2-binary==2.9.10
|
||||
jinja2==3.1.5
|
||||
python-dotenv==1.0.1
|
||||
pydantic-settings==2.7.0
|
||||
alembic==1.14.0
|
||||
python-multipart==0.0.20
|
||||
itsdangerous==2.2.0
|
||||
|
||||
Reference in New Issue
Block a user