Add Pitopn digital shop with FastAPI, Postgres 17, and Docker Compose

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-25 05:21:34 +03:00
co-authored by Cursor
parent fb00211c48
commit 08cc4b0f1b
16 changed files with 1126 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
.git
.gitignore
.env
.env.*
!.env.example
__pycache__
*.pyc
*.pyo
.venv
venv
.idea
.vscode
*.md
.DS_Store
+14
View File
@@ -0,0 +1,14 @@
# Application
APP_NAME=Pitopn
SECRET_KEY=change-me-to-a-long-random-string
DEBUG=false
# Database (PostgreSQL 17)
POSTGRES_USER=pitopn
POSTGRES_PASSWORD=change-me-strong-password
POSTGRES_DB=pitopn
POSTGRES_HOST=db
POSTGRES_PORT=5432
# Public URL (set in Dokploy if needed)
# PUBLIC_URL=https://shop.example.com
+10
View File
@@ -0,0 +1,10 @@
.env
.venv/
venv/
__pycache__/
*.py[cod]
*.egg-info/
.idea/
.vscode/
.DS_Store
*.log
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app ./app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:8000/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1
View File
@@ -0,0 +1 @@
"""Pitopn digital goods shop."""
+29
View File
@@ -0,0 +1,29 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "Pitopn"
secret_key: str = "dev-secret"
debug: bool = False
postgres_user: str = "pitopn"
postgres_password: str = "pitopn"
postgres_db: str = "pitopn"
postgres_host: str = "localhost"
postgres_port: int = 5432
@property
def database_url(self) -> str:
return (
f"postgresql+psycopg2://{self.postgres_user}:{self.postgres_password}"
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
)
@lru_cache
def get_settings() -> Settings:
return Settings()
+29
View File
@@ -0,0 +1,29 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
settings = get_settings()
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+97
View File
@@ -0,0 +1,97 @@
import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI, Request
from fastapi.responses import HTMLResponse
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 app.config import get_settings
from app.database import Base, engine, get_db
from app.models import Category, Product
from app.seed import seed_if_empty
BASE_DIR = Path(__file__).resolve().parent
settings = get_settings()
def wait_for_db(retries: int = 30, delay: float = 1.0) -> None:
for attempt in range(1, retries + 1):
try:
with engine.connect() as conn:
conn.exec_driver_sql("SELECT 1")
return
except OperationalError:
if attempt == retries:
raise
time.sleep(delay)
@asynccontextmanager
async def lifespan(_: FastAPI):
wait_for_db()
Base.metadata.create_all(bind=engine)
from app.database import SessionLocal
db = SessionLocal()
try:
seed_if_empty(db)
finally:
db.close()
yield
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def format_price(value) -> str:
try:
amount = float(value)
except (TypeError, ValueError):
return str(value)
formatted = f"{amount:,.0f}".replace(",", " ")
return f"{formatted}"
templates.env.filters["rub"] = format_price
@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,
},
)
+37
View File
@@ -0,0 +1,37 @@
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 app.database import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
name: Mapped[str] = mapped_column(String(120))
description: Mapped[str] = mapped_column(Text, default="")
sort_order: Mapped[int] = mapped_column(default=0)
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(120), unique=True, index=True)
title: Mapped[str] = mapped_column(String(200))
short_description: Mapped[str] = mapped_column(String(300))
description: Mapped[str] = mapped_column(Text, default="")
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
currency: Mapped[str] = mapped_column(String(3), default="RUB")
category_slug: Mapped[str] = mapped_column(String(64), index=True)
image_gradient: Mapped[str] = mapped_column(String(120), default="mint")
is_featured: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
delivery_note: Mapped[str] = mapped_column(String(200), default="Мгновенная выдача")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
+118
View File
@@ -0,0 +1,118 @@
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Category, Product
CATEGORIES = [
{
"slug": "keys",
"name": "Игровые ключи",
"description": "Steam, Epic, Origin и другие платформы",
"sort_order": 1,
},
{
"slug": "software",
"name": "Софт",
"description": "Лицензии и подписки для работы",
"sort_order": 2,
},
{
"slug": "accounts",
"name": "Аккаунты",
"description": "Готовые профили с контентом",
"sort_order": 3,
},
{
"slug": "giftcards",
"name": "Подарочные карты",
"description": "Цифровые коды пополнения",
"sort_order": 4,
},
]
PRODUCTS = [
{
"slug": "steam-wallet-1000",
"title": "Steam Wallet 1000 ₽",
"short_description": "Код пополнения кошелька Steam на 1000 рублей",
"description": "Цифровой код активируется в клиенте Steam. Доставка сразу после оплаты.",
"price": Decimal("1049.00"),
"category_slug": "giftcards",
"image_gradient": "mint",
"is_featured": True,
"delivery_note": "Код на email за 1–3 минуты",
},
{
"slug": "adobe-cc-1m",
"title": "Adobe Creative Cloud — 1 месяц",
"short_description": "Полный доступ к Photoshop, Premiere, Illustrator",
"description": "Официальная подписка на месяц. Данные для входа приходят автоматически.",
"price": Decimal("1890.00"),
"category_slug": "software",
"image_gradient": "coral",
"is_featured": True,
"delivery_note": "Доступ в личном кабинете",
},
{
"slug": "cyberpunk-steam",
"title": "Cyberpunk 2077 — Steam Key",
"short_description": "Полная версия с обновлениями для Steam",
"description": "Ключ активации для региона RU/CIS. Работает с аккаунтом Steam.",
"price": Decimal("1299.00"),
"category_slug": "keys",
"image_gradient": "ink",
"is_featured": True,
"delivery_note": "Ключ сразу после оплаты",
},
{
"slug": "spotify-premium-3m",
"title": "Spotify Premium — 3 месяца",
"short_description": "Музыка без рекламы и офлайн-режим",
"description": "Подписка активируется на ваш аккаунт или выдаётся новый профиль.",
"price": Decimal("799.00"),
"category_slug": "accounts",
"image_gradient": "sand",
"is_featured": True,
"delivery_note": "Активация до 15 минут",
},
{
"slug": "windows-11-pro",
"title": "Windows 11 Pro Key",
"short_description": "Розничный ключ активации Windows 11 Pro",
"description": "Подходит для чистой установки и апгрейда. Инструкция в письме.",
"price": Decimal("2490.00"),
"category_slug": "software",
"image_gradient": "mint",
"is_featured": False,
"delivery_note": "Ключ на email",
},
{
"slug": "playstation-1500",
"title": "PlayStation Store 1500 ₽",
"short_description": "Карта пополнения аккаунта PSN",
"description": "Цифровой ваучер для российского региона PlayStation Network.",
"price": Decimal("1549.00"),
"category_slug": "giftcards",
"image_gradient": "coral",
"is_featured": False,
"delivery_note": "Код за несколько минут",
},
]
def seed_if_empty(db: Session) -> None:
has_categories = db.scalar(select(Category.id).limit(1)) is not None
if not has_categories:
for item in CATEGORIES:
db.add(Category(**item))
db.commit()
has_products = db.scalar(select(Product.id).limit(1)) is not None
if not has_products:
for item in PRODUCTS:
db.add(Product(**item))
db.commit()
+509
View File
@@ -0,0 +1,509 @@
:root {
--bg: #e8efe9;
--bg-deep: #d5e2d8;
--ink: #12201a;
--ink-soft: #3d5248;
--accent: #0f7a4c;
--accent-hot: #e85d04;
--surface: rgba(255, 255, 255, 0.55);
--line: rgba(18, 32, 26, 0.12);
--radius: 18px;
--font-display: "Syne", sans-serif;
--font-body: "Manrope", sans-serif;
--shadow: 0 24px 60px rgba(18, 32, 26, 0.12);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: var(--font-body);
background:
radial-gradient(1200px 700px at 10% -10%, #c8e6d3 0%, transparent 55%),
radial-gradient(900px 600px at 100% 0%, #f4d6b8 0%, transparent 45%),
linear-gradient(165deg, var(--bg) 0%, var(--bg-deep) 100%);
line-height: 1.5;
}
.noise {
pointer-events: none;
position: fixed;
inset: 0;
opacity: 0.04;
z-index: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.site-header,
main,
.site-footer {
position: relative;
z-index: 1;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.1rem clamp(1.2rem, 4vw, 3rem);
max-width: 1200px;
margin: 0 auto;
}
.brand {
font-family: var(--font-display);
font-weight: 800;
font-size: 1.35rem;
letter-spacing: -0.03em;
color: var(--ink);
text-decoration: none;
}
.nav {
display: flex;
gap: 1.4rem;
}
.nav a,
.header-cta {
color: var(--ink-soft);
text-decoration: none;
font-weight: 600;
font-size: 0.95rem;
}
.header-cta {
color: var(--accent);
}
.hero {
min-height: calc(100svh - 72px);
display: grid;
grid-template-columns: 1.05fr 0.95fr;
align-items: end;
gap: clamp(1.5rem, 4vw, 3rem);
max-width: 1200px;
margin: 0 auto;
padding: clamp(1rem, 3vw, 2rem) clamp(1.2rem, 4vw, 3rem) clamp(2rem, 5vw, 4rem);
}
.hero-copy {
padding-bottom: clamp(1rem, 4vw, 3rem);
animation: rise 0.9s ease both;
}
.brand-mark {
margin: 0 0 0.8rem;
font-family: var(--font-display);
font-size: clamp(2.8rem, 8vw, 5.5rem);
font-weight: 800;
letter-spacing: -0.05em;
line-height: 0.9;
color: var(--ink);
}
.hero h1 {
margin: 0 0 1rem;
max-width: 14ch;
font-family: var(--font-display);
font-size: clamp(1.8rem, 4.2vw, 3rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.05;
}
.hero-lead {
margin: 0 0 1.8rem;
max-width: 34ch;
color: var(--ink-soft);
font-size: 1.08rem;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
padding: 0.85rem 1.35rem;
font: inherit;
font-weight: 700;
text-decoration: none;
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease, color 0.2s ease;
}
.btn:hover {
transform: translateY(-2px);
}
.btn-primary {
background: var(--ink);
color: #f4faf6;
}
.btn-ghost {
background: transparent;
color: var(--ink);
box-shadow: inset 0 0 0 1.5px var(--line);
}
.btn-small {
padding: 0.55rem 1rem;
background: var(--accent);
color: #fff;
font-size: 0.9rem;
}
.hero-visual {
position: relative;
min-height: min(62vh, 560px);
animation: fade-in 1.1s ease 0.15s both;
}
.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(
-18deg,
transparent 0 18px,
rgba(255, 255, 255, 0.05) 18px 19px
);
clip-path: polygon(8% 0, 100% 0, 100% 88%, 0 100%);
box-shadow: var(--shadow);
}
.hero-orbit {
position: absolute;
width: 42%;
aspect-ratio: 1;
right: 12%;
top: 18%;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
animation: spin 18s linear infinite;
}
.hero-orbit::after {
content: "";
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
background: #f4d6b8;
top: 8%;
left: 50%;
transform: translateX(-50%);
}
section {
max-width: 1200px;
margin: 0 auto;
padding: clamp(2.5rem, 6vw, 5rem) clamp(1.2rem, 4vw, 3rem);
}
.section-head {
margin-bottom: 1.8rem;
}
.section-head h2 {
margin: 0 0 0.35rem;
font-family: var(--font-display);
font-size: clamp(1.8rem, 3vw, 2.4rem);
letter-spacing: -0.03em;
}
.section-head p {
margin: 0;
color: var(--ink-soft);
}
.category-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
.category-link {
display: block;
padding: 1.2rem 1rem;
text-decoration: none;
color: inherit;
border-top: 2px solid var(--ink);
transition: background 0.2s ease;
}
.category-link:hover {
background: var(--surface);
}
.category-name {
display: block;
font-family: var(--font-display);
font-weight: 700;
margin-bottom: 0.35rem;
}
.category-desc {
color: var(--ink-soft);
font-size: 0.92rem;
}
.featured-rail {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.2rem;
margin-bottom: 1.5rem;
}
.product-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.2rem;
}
.product {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--surface);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.product-media {
min-height: 140px;
background: linear-gradient(135deg, #0f7a4c, #1b4332);
}
.gradient-mint .product-media {
background: linear-gradient(135deg, #2d6a4f, #95d5b2);
}
.gradient-coral .product-media {
background: linear-gradient(135deg, #e85d04, #f4a261);
}
.gradient-ink .product-media {
background: linear-gradient(135deg, #12201a, #40916c);
}
.gradient-sand .product-media {
background: linear-gradient(135deg, #bc6c25, #e9c46a);
}
.product-body {
padding: 1.1rem 1.15rem 1.2rem;
display: flex;
flex-direction: column;
gap: 0.55rem;
flex: 1;
}
.product-body h3 {
margin: 0;
font-family: var(--font-display);
font-size: 1.15rem;
letter-spacing: -0.02em;
}
.product-body p {
margin: 0;
color: var(--ink-soft);
font-size: 0.94rem;
}
.product-tag {
font-size: 0.78rem !important;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent) !important;
}
.product-meta {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding-top: 0.6rem;
}
.price {
font-family: var(--font-display);
font-weight: 800;
font-size: 1.2rem;
}
.steps {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.4rem;
}
.steps li {
padding-top: 1rem;
border-top: 2px solid var(--ink);
}
.step-num {
display: block;
font-family: var(--font-display);
font-weight: 800;
color: var(--accent-hot);
margin-bottom: 0.6rem;
}
.steps h3 {
margin: 0 0 0.4rem;
font-family: var(--font-display);
}
.steps p {
margin: 0;
color: var(--ink-soft);
}
.site-footer {
max-width: 1200px;
margin: 0 auto;
padding: 2rem clamp(1.2rem, 4vw, 3rem) 3rem;
border-top: 1px solid var(--line);
}
.footer-brand {
font-family: var(--font-display);
font-weight: 800;
font-size: 1.4rem;
margin-bottom: 0.4rem;
}
.site-footer p {
margin: 0.25rem 0;
color: var(--ink-soft);
}
.footer-copy {
margin-top: 1rem !important;
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);
transition: opacity 0.55s ease, transform 0.55s ease;
}
.reveal.is-visible {
opacity: 1;
transform: none;
}
.empty {
grid-column: 1 / -1;
color: var(--ink-soft);
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 960px) {
.hero {
grid-template-columns: 1fr;
min-height: auto;
}
.hero-visual {
min-height: 280px;
order: -1;
}
.category-list,
.featured-rail,
.product-grid,
.steps {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 720px) {
.nav {
display: none;
}
.category-list,
.featured-rail,
.product-grid,
.steps {
grid-template-columns: 1fr;
}
.brand-mark {
font-size: clamp(2.4rem, 12vw, 3.4rem);
}
}
+39
View File
@@ -0,0 +1,39 @@
(() => {
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(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.15 }
);
reveals.forEach((el) => observer.observe(el));
} else {
reveals.forEach((el) => el.classList.add("is-visible"));
}
})();
+38
View File
@@ -0,0 +1,38 @@
<!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>
<meta
name="description"
content="{{ app_name }} — мгновенная выдача ключей, софта и подарочных карт."
/>
<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>
<div class="noise" aria-hidden="true"></div>
<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>
</nav>
<a class="header-cta" href="#catalog">В каталог</a>
</header>
<main>{% block content %}{% endblock %}</main>
<footer class="site-footer" id="support">
<div class="footer-brand">{{ app_name }}</div>
<p>Цифровые товары с мгновенной выдачей. Вопросы: support@pitopn.shop</p>
<p class="footer-copy">© {{ app_name }} 2026</p>
</footer>
<script src="/static/js/main.js" defer></script>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block content %}
<section class="hero">
<div class="hero-copy">
<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>
</div>
</div>
<div class="hero-visual" aria-hidden="true">
<div class="hero-plane"></div>
<div class="hero-orbit"></div>
</div>
</section>
<section class="categories" aria-label="Категории">
<div class="section-head">
<h2>Категории</h2>
<p>Выберите тип цифрового товара</p>
</div>
<ul class="category-list">
{% for category in categories %}
<li>
<a class="category-link" href="#catalog">
<span class="category-name">{{ category.name }}</span>
<span class="category-desc">{{ category.description }}</span>
</a>
</li>
{% endfor %}
</ul>
</section>
<section class="catalog" id="catalog">
<div class="section-head">
<h2>Каталог</h2>
<p>Популярные позиции с автоматической выдачей</p>
</div>
{% if featured %}
<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>
<div class="product-body">
<p class="product-tag">{{ product.delivery_note }}</p>
<h3>{{ product.title }}</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>
</div>
</div>
</article>
{% endfor %}
</div>
{% endif %}
<div class="product-grid">
{% for product in products %}
<article class="product gradient-{{ product.image_gradient }} reveal">
<div class="product-media"></div>
<div class="product-body">
<p class="product-tag">{{ product.delivery_note }}</p>
<h3>{{ product.title }}</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>
</div>
</div>
</article>
{% else %}
<p class="empty">Товары скоро появятся.</p>
{% endfor %}
</div>
</section>
<section class="how" id="how">
<div class="section-head">
<h2>Как купить</h2>
<p>Три шага до цифрового кода</p>
</div>
<ol class="steps">
<li>
<span class="step-num">01</span>
<h3>Выберите товар</h3>
<p>Ключ, подписка или карта пополнения из каталога.</p>
</li>
<li>
<span class="step-num">02</span>
<h3>Оплатите</h3>
<p>Безопасная оплата картой или через доступные методы.</p>
</li>
<li>
<span class="step-num">03</span>
<h3>Получите сразу</h3>
<p>Код или доступ приходят автоматически после оплаты.</p>
</li>
</ol>
</section>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-pitopn}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-strong-password}
POSTGRES_DB: ${POSTGRES_DB:-pitopn}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pitopn} -d ${POSTGRES_DB:-pitopn}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
networks:
- internal
web:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
APP_NAME: ${APP_NAME:-Pitopn}
SECRET_KEY: ${SECRET_KEY:-change-me-to-a-long-random-string}
DEBUG: ${DEBUG:-false}
POSTGRES_USER: ${POSTGRES_USER:-pitopn}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-strong-password}
POSTGRES_DB: ${POSTGRES_DB:-pitopn}
POSTGRES_HOST: db
POSTGRES_PORT: 5432
ports:
- "${APP_PORT:-8000}:8000"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 25s
networks:
- internal
volumes:
postgres_data:
networks:
internal:
driver: bridge
+8
View File
@@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
jinja2==3.1.5
python-dotenv==1.0.1
pydantic-settings==2.7.0
alembic==1.14.0