From 08cc4b0f1bb052e97fdd39167ccf573a1260541d Mon Sep 17 00:00:00 2001 From: orohi Date: Sat, 25 Jul 2026 05:21:34 +0300 Subject: [PATCH] Add Pitopn digital shop with FastAPI, Postgres 17, and Docker Compose Co-authored-by: Cursor --- .dockerignore | 14 ++ .env.example | 14 ++ .gitignore | 10 + Dockerfile | 23 ++ app/__init__.py | 1 + app/config.py | 29 +++ app/database.py | 29 +++ app/main.py | 97 ++++++++ app/models.py | 37 +++ app/seed.py | 118 +++++++++ app/static/css/style.css | 509 +++++++++++++++++++++++++++++++++++++++ app/static/js/main.js | 39 +++ app/templates/base.html | 38 +++ app/templates/index.html | 109 +++++++++ docker-compose.yml | 51 ++++ requirements.txt | 8 + 16 files changed, 1126 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/seed.py create mode 100644 app/static/css/style.css create mode 100644 app/static/js/main.js create mode 100644 app/templates/base.html create mode 100644 app/templates/index.html create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3556ff2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.env +.env.* +!.env.example +__pycache__ +*.pyc +*.pyo +.venv +venv +.idea +.vscode +*.md +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7708c90 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..306152f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +.venv/ +venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.idea/ +.vscode/ +.DS_Store +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d05048 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..feaa26d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Pitopn digital goods shop.""" diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..78bb024 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..64c6396 --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e0d35d5 --- /dev/null +++ b/app/main.py @@ -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, + }, + ) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..27897b1 --- /dev/null +++ b/app/models.py @@ -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() + ) diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..f545404 --- /dev/null +++ b/app/seed.py @@ -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() diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..512cf32 --- /dev/null +++ b/app/static/css/style.css @@ -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); + } +} diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..35bbeb9 --- /dev/null +++ b/app/static/js/main.js @@ -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")); + } +})(); diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..1280e18 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,38 @@ + + + + + + {% block title %}{{ app_name }} — магазин цифровых товаров{% endblock %} + + + + + + + + + +
{% block content %}{% endblock %}
+
+ +

Цифровые товары с мгновенной выдачей. Вопросы: support@pitopn.shop

+ +
+ + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..f21090c --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

{{ app_name }}

+

Цифровые товары без ожидания

+

+ Ключи, софт и подарочные карты — оплатил и сразу получил доступ в личном кабинете. +

+ +
+ +
+ +
+
+

Категории

+

Выберите тип цифрового товара

+
+ +
+ +
+
+

Каталог

+

Популярные позиции с автоматической выдачей

+
+ + {% if featured %} + + {% endif %} + +
+ {% for product in products %} +
+
+
+

{{ product.delivery_note }}

+

{{ product.title }}

+

{{ product.short_description }}

+
+ {{ product.price | rub }} + +
+
+
+ {% else %} +

Товары скоро появятся.

+ {% endfor %} +
+
+ +
+
+

Как купить

+

Три шага до цифрового кода

+
+
    +
  1. + 01 +

    Выберите товар

    +

    Ключ, подписка или карта пополнения из каталога.

    +
  2. +
  3. + 02 +

    Оплатите

    +

    Безопасная оплата картой или через доступные методы.

    +
  4. +
  5. + 03 +

    Получите сразу

    +

    Код или доступ приходят автоматически после оплаты.

    +
  6. +
+
+ + +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cacec63 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1225b4b --- /dev/null +++ b/requirements.txt @@ -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