Add admin panel, cart, checkout, and digital key delivery

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-25 05:32:05 +03:00
co-authored by Cursor
parent 08cc4b0f1b
commit 54eaff4c70
32 changed files with 1982 additions and 105 deletions
+4
View File
@@ -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
+24 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
+414
View File
@@ -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),
)
+260
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
-20
View File
@@ -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(
+32
View File
@@ -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>
+56
View File
@@ -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 %}
+44
View File
@@ -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 %}
+35
View File
@@ -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>
+25
View File
@@ -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 %}
+36
View File
@@ -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 %}
+70
View File
@@ -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 %}
+48
View File
@@ -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 %}
+6 -4
View File
@@ -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">
+57
View File
@@ -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 %}
+37
View File
@@ -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
View File
@@ -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 %}
+25
View File
@@ -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 %}
+31
View File
@@ -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 %}
+21
View File
@@ -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 %}
+32
View File
@@ -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 %}
+2
View File
@@ -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
View File
@@ -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