31 changed files with 1150 additions and 256 deletions
+5 -1
View File
@@ -9,7 +9,11 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /shop ./cmd/shop ARG APP_VERSION=dev
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X shop/internal/version.Version=${APP_VERSION}" \
-o /shop ./cmd/shop
# Runtime stage # Runtime stage
FROM alpine:3.21 FROM alpine:3.21
+69 -113
View File
@@ -1,8 +1,20 @@
# Shop — Интернет-магазин # Shop — Интернет-магазин
Главная страница интернет-магазина на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy** (обратный прокси + SSL). Полнофункциональный интернет-магазин на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy**.
Проект настроен для развёртывания на **Ubuntu 22.04 / 24.04**. **Текущая версия:** `v0.30-beta`
## Возможности
| Модуль | Описание |
|--------|----------|
| Каталог | Главная, категории, карточки товаров со скидками |
| Товар | Страница `/product/{id}` с описанием, фото и скриншотами |
| Категории | `/category/{slug}` — управление в админке |
| Корзина | Добавление, изменение количества, расчёт со скидкой |
| Заказы | Оформление гостем или зарегистрированным пользователем |
| Личный кабинет | Профиль, история заказов со статус-таймлайном |
| Админка | Товары, категории, скриншоты, редактор Quill, статистика |
## Стек ## Стек
@@ -12,164 +24,108 @@
| База данных | PostgreSQL 17 | | База данных | PostgreSQL 17 |
| Прокси / SSL | Caddy 2 | | Прокси / SSL | Caddy 2 |
| ОС | Ubuntu 22.04 / 24.04 | | ОС | Ubuntu 22.04 / 24.04 |
| Контейнеризация | Docker Compose |
## Быстрый старт на Ubuntu ## Быстрый старт (Ubuntu)
```bash ```bash
# Клонировать репозиторий
git clone https://git.evilfox.cc/test2/shop-go.git /opt/shop git clone https://git.evilfox.cc/test2/shop-go.git /opt/shop
cd /opt/shop cd /opt/shop
sudo bash setup.sh
# 1. Установить Docker (один раз, с sudo)
sudo ./scripts/install-ubuntu.sh
# Перелогиниться или:
newgrp docker
# 2. Запустить магазин
chmod +x scripts/*.sh
./scripts/deploy.sh
``` ```
Откройте в браузере: Или обновление существующего:
- `http://IP_СЕРВЕРА` — магазин
- `http://IP_СЕРВЕРА/admin/login` — админка
Или через Make:
```bash ```bash
make install # sudo, установка Docker cd /opt/shop
make deploy # первый запуск bash scripts/redeploy.sh
make logs # просмотр логов
``` ```
## Продакшен с доменом и SSL Проверка версии:
1. Настройте DNS: A-запись домена → IP сервера Ubuntu
2. Откройте порты 80 и 443 (скрипт `install-ubuntu.sh` настраивает ufw)
3. Включите SSL:
```bash ```bash
./scripts/enable-ssl.sh shop.example.com admin@example.com curl http://localhost/health
# {"status":"ok","version":"v0.30-beta"}
``` ```
Или: ## Маршруты магазина
```bash | Путь | Описание |
make ssl DOMAIN=shop.example.com EMAIL=admin@example.com |------|----------|
``` | `/` | Главная |
| `/product/{id}` | Страница товара |
| `/category/{slug}` | Категория |
| `/cart` | Корзина |
| `/checkout` | Оформление заказа |
| `/register`, `/login` | Регистрация и вход |
| `/account` | Личный кабинет |
| `/admin/` | Админ-панель |
Caddy автоматически получит сертификат Let's Encrypt. ## Скидки
Вручную через `.env`: В админке при редактировании товара укажите **Цена** и **Цена со скидкой**.
На сайте отображается старая цена зачёркнутой и бейдж `-N%`.
```env ## Категории
DOMAIN=shop.example.com
CADDY_EMAIL=admin@example.com
CADDYFILE=./Caddyfile.prod
```
Затем: `docker compose up -d --force-recreate caddy` Админка → **Категории** → добавьте категорию → при создании товара выберите её в списке.
## Автозапуск при загрузке Ubuntu ## Статусы заказов
```bash | Код | Отображение |
sudo cp systemd/shop.service /etc/systemd/system/ |-----|-------------|
# Отредактируйте WorkingDirectory, если проект не в /opt/shop | `new` | Новый |
sudo systemctl daemon-reload | `confirmed` | Подтверждён |
sudo systemctl enable --now shop | `processing` | В обработке |
``` | `shipped` | Отправлен |
| `delivered` | Доставлен |
| `cancelled` | Отменён |
В личном кабинете — цветной бейдж и таймлайн прогресса.
## Админ ## Админ
Администратор создаётся автоматически при старте из `.env`:
```env ```env
ADMIN_EMAIL=admin@shop.local ADMIN_EMAIL=admin@shop.local
ADMIN_PASSWORD=change_me ADMIN_PASSWORD=change_me
SESSION_SECRET=your_random_secret_key_here SESSION_SECRET=your_random_secret_key_here
``` ```
Скрипт `deploy.sh` генерирует пароли автоматически при первом запуске.
Вход: `/admin/login` Вход: `/admin/login`
При изменении `ADMIN_PASSWORD` пароль обновится в БД при следующем запуске. ## SSL
## Сервисы
| Сервис | Порт | Описание |
|--------|------|----------|
| Caddy | 80, 443 | Обратный прокси, SSL |
| App | 8080 (внутри) | Go-приложение |
| PostgreSQL | 5432 (внутри) | База данных |
## Обновление на сервере
```bash ```bash
./scripts/update.sh ./scripts/enable-ssl.sh shop.example.com admin@example.com
# или
make update
``` ```
## Локальная разработка (Ubuntu) ## Обновление
```bash ```bash
# Только PostgreSQL в Docker bash scripts/redeploy.sh
docker compose up db -d
export DB_HOST=localhost DB_USER=shop DB_PASSWORD=shop_secret DB_NAME=shop
export ADMIN_EMAIL=admin@shop.local ADMIN_PASSWORD=change_me
go mod tidy
go run ./cmd/shop
# http://localhost:8080
``` ```
## Структура проекта Скрипт делает `git reset --hard`, пересборку Docker без кэша и проверку `/health`.
## Структура
``` ```
shop/ shop/
├── cmd/shop/main.go ├── cmd/shop/ # Точка входа
├── internal/ ├── internal/
│ ├── handlers/ # HTTP, шаблоны, статика
│ ├── repository/ # БД
│ ├── orderstatus/ # Статусы заказов
│ └── ...
├── migrations/ ├── migrations/
├── scripts/ ├── scripts/
│ ├── install-ubuntu.sh # установка Docker + ufw │ ├── deploy.sh
│ ├── deploy.sh # первый запуск │ ├── redeploy.sh # Полное обновление
── enable-ssl.sh # SSL для домена ── update.sh
│ └── update.sh # обновление └── docker-compose.yml
├── systemd/shop.service # автозапуск
├── Caddyfile # HTTP (IP / localhost)
├── Caddyfile.prod # HTTPS (домен)
├── docker-compose.yml
├── Makefile
└── Dockerfile
``` ```
## API ## Требования
| Метод | Путь | Описание | - Ubuntu 22.04 / 24.04
|-------|------|----------| - 1 CPU, 1 GB RAM
| GET | `/` | Главная страница | - Порты 22, 80, 443
| GET | `/health` | Health check |
| GET | `/static/*` | CSS и статика |
| GET | `/admin/login` | Страница входа |
| POST | `/admin/login` | Авторизация |
| GET | `/admin/` | Админ-панель |
| POST | `/admin/logout` | Выход |
## Остановка
```bash
docker compose down
# С удалением данных БД
docker compose down -v
```
## Требования к серверу Ubuntu
- 1 CPU, 1 GB RAM (минимум)
- Ubuntu 22.04 или 24.04 LTS
- Открытые порты 22 (SSH), 80, 443
+10 -3
View File
@@ -16,6 +16,7 @@ import (
"shop/internal/middleware" "shop/internal/middleware"
"shop/internal/repository" "shop/internal/repository"
"shop/internal/upload" "shop/internal/upload"
"shop/internal/version"
) )
func main() { func main() {
@@ -33,6 +34,7 @@ func main() {
} }
productRepo := repository.NewProductRepository(pool) productRepo := repository.NewProductRepository(pool)
categoryRepo := repository.NewCategoryRepository(pool)
adminRepo := repository.NewAdminRepository(pool) adminRepo := repository.NewAdminRepository(pool)
statsRepo := repository.NewStatsRepository(pool) statsRepo := repository.NewStatsRepository(pool)
userRepo := repository.NewUserRepository(pool) userRepo := repository.NewUserRepository(pool)
@@ -61,10 +63,10 @@ func main() {
cartSession := auth.NewSession(cfg.SessionSecret, "shop_cart_key", "/") cartSession := auth.NewSession(cfg.SessionSecret, "shop_cart_key", "/")
customer := handlers.NewCustomerHandler( customer := handlers.NewCustomerHandler(
productRepo, userRepo, cartRepo, orderRepo, statsRepo, productRepo, categoryRepo, userRepo, cartRepo, orderRepo, statsRepo,
userSession, cartSession, tmpl, userSession, cartSession, tmpl,
) )
admin := handlers.NewAdminHandler(adminRepo, productRepo, statsRepo, storage, adminSession, tmpl) admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, statsRepo, storage, adminSession, tmpl)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler())) mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler()))
@@ -72,6 +74,8 @@ func main() {
mux.HandleFunc("GET /health", customer.Health) mux.HandleFunc("GET /health", customer.Health)
mux.HandleFunc("GET /{$}", customer.Home) mux.HandleFunc("GET /{$}", customer.Home)
mux.HandleFunc("GET /product/{id}", customer.ProductPage)
mux.HandleFunc("GET /category/{slug}", customer.CategoryPage)
mux.HandleFunc("GET /register", customer.RegisterPage) mux.HandleFunc("GET /register", customer.RegisterPage)
mux.HandleFunc("POST /register", customer.Register) mux.HandleFunc("POST /register", customer.Register)
mux.HandleFunc("GET /login", customer.LoginPage) mux.HandleFunc("GET /login", customer.LoginPage)
@@ -96,6 +100,9 @@ func main() {
mux.HandleFunc("GET /admin/products/{id}/edit", admin.ProductEdit) mux.HandleFunc("GET /admin/products/{id}/edit", admin.ProductEdit)
mux.HandleFunc("POST /admin/products/{id}/delete", admin.ProductDelete) mux.HandleFunc("POST /admin/products/{id}/delete", admin.ProductDelete)
mux.HandleFunc("POST /admin/products/{id}/images/{imageId}/delete", admin.ImageDelete) mux.HandleFunc("POST /admin/products/{id}/images/{imageId}/delete", admin.ImageDelete)
mux.HandleFunc("GET /admin/categories", admin.CategoryList)
mux.HandleFunc("POST /admin/categories", admin.CategoryCreate)
mux.HandleFunc("POST /admin/categories/{id}/delete", admin.CategoryDelete)
addr := ":" + getEnv("APP_PORT", "8080") addr := ":" + getEnv("APP_PORT", "8080")
srv := &http.Server{ srv := &http.Server{
@@ -107,7 +114,7 @@ func main() {
} }
go func() { go func() {
log.Printf("server listening on %s", addr) log.Printf("Shop %s listening on %s", version.Version, addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err) log.Fatalf("listen: %v", err)
} }
+5
View File
@@ -14,6 +14,7 @@ services:
- ./migrations/002_admins.sql:/docker-entrypoint-initdb.d/002_admins.sql:ro - ./migrations/002_admins.sql:/docker-entrypoint-initdb.d/002_admins.sql:ro
- ./migrations/003_admin_features.sql:/docker-entrypoint-initdb.d/003_admin_features.sql:ro - ./migrations/003_admin_features.sql:/docker-entrypoint-initdb.d/003_admin_features.sql:ro
- ./migrations/004_users_cart_orders.sql:/docker-entrypoint-initdb.d/004_users_cart_orders.sql:ro - ./migrations/004_users_cart_orders.sql:/docker-entrypoint-initdb.d/004_users_cart_orders.sql:ro
- ./migrations/005_categories_sale.sql:/docker-entrypoint-initdb.d/005_categories_sale.sql:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"]
interval: 5s interval: 5s
@@ -26,6 +27,9 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
APP_VERSION: ${APP_VERSION:-dev}
BUILD_TIME: ${BUILD_TIME:-}
container_name: shop-app container_name: shop-app
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
@@ -34,6 +38,7 @@ services:
DB_PORT: "5432" DB_PORT: "5432"
APP_PORT: "8080" APP_PORT: "8080"
UPLOAD_DIR: /app/data/uploads UPLOAD_DIR: /app/data/uploads
APP_VERSION: ${APP_VERSION:-dev}
volumes: volumes:
- uploads:/app/data/uploads - uploads:/app/data/uploads
depends_on: depends_on:
+19
View File
@@ -77,6 +77,25 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
quantity INT NOT NULL CHECK (quantity > 0) quantity INT NOT NULL CHECK (quantity > 0)
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id)`, `CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id)`,
`CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INT REFERENCES categories(id) ON DELETE SET NULL`,
`ALTER TABLE products ADD COLUMN IF NOT EXISTS sale_price NUMERIC(10,2)`,
`INSERT INTO categories (name, slug, sort_order) VALUES
('Электроника', 'elektronika', 1),
('Одежда', 'odezhda', 2),
('Дом и сад', 'dom-i-sad', 3),
('Аксессуары', 'aksessuary', 4),
('Разное', 'raznoe', 5)
ON CONFLICT (slug) DO NOTHING`,
`UPDATE products p SET category_id = c.id
FROM categories c WHERE p.category = c.name AND p.category_id IS NULL`,
} }
for _, q := range queries { for _, q := range queries {
+27 -17
View File
@@ -13,12 +13,13 @@ import (
) )
type AdminHandler struct { type AdminHandler struct {
repo *repository.AdminRepository repo *repository.AdminRepository
products *repository.ProductRepository products *repository.ProductRepository
stats *repository.StatsRepository categories *repository.CategoryRepository
storage *upload.Storage stats *repository.StatsRepository
session *auth.Session storage *upload.Storage
templates *template.Template session *auth.Session
templates *template.Template
} }
type adminLayoutData struct { type adminLayoutData struct {
@@ -52,28 +53,37 @@ type adminProductsData struct {
} }
type adminProductFormData struct { type adminProductFormData struct {
Title string Title string
Email string Email string
Product models.Product Product models.Product
IsNew bool Categories []models.Category
Error string IsNew bool
Error string
}
type adminCategoriesData struct {
Title string
Email string
Categories []models.Category
} }
func NewAdminHandler( func NewAdminHandler(
adminRepo *repository.AdminRepository, adminRepo *repository.AdminRepository,
productRepo *repository.ProductRepository, productRepo *repository.ProductRepository,
categoryRepo *repository.CategoryRepository,
statsRepo *repository.StatsRepository, statsRepo *repository.StatsRepository,
storage *upload.Storage, storage *upload.Storage,
session *auth.Session, session *auth.Session,
templates *template.Template, templates *template.Template,
) *AdminHandler { ) *AdminHandler {
return &AdminHandler{ return &AdminHandler{
repo: adminRepo, repo: adminRepo,
products: productRepo, products: productRepo,
stats: statsRepo, categories: categoryRepo,
storage: storage, stats: statsRepo,
session: session, storage: storage,
templates: templates, session: session,
templates: templates,
} }
} }
+64
View File
@@ -0,0 +1,64 @@
package handlers
import (
"context"
"log"
"net/http"
"strconv"
"shop/internal/models"
)
func (h *AdminHandler) CategoryList(w http.ResponseWriter, r *http.Request) {
email, ok := h.requireAuth(w, r)
if !ok {
return
}
list, err := h.categories.All(r.Context())
if err != nil {
log.Printf("categories: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
h.render(w, "admin_categories.html", adminCategoriesData{
Title: "Категории",
Email: email,
Categories: list,
})
}
func (h *AdminHandler) CategoryCreate(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireAuth(w, r); !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
desc := r.FormValue("description")
if name == "" {
http.Redirect(w, r, "/admin/categories", http.StatusSeeOther)
return
}
if _, err := h.categories.Create(r.Context(), name, desc); err != nil {
log.Printf("category create: %v", err)
}
http.Redirect(w, r, "/admin/categories", http.StatusSeeOther)
}
func (h *AdminHandler) CategoryDelete(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireAuth(w, r); !ok {
return
}
id, _ := strconv.Atoi(r.PathValue("id"))
if id > 0 {
_ = h.categories.Delete(r.Context(), id)
}
http.Redirect(w, r, "/admin/categories", http.StatusSeeOther)
}
func (h *AdminHandler) loadCategories(ctx context.Context) []models.Category {
list, _ := h.categories.All(ctx)
return list
}
+30 -13
View File
@@ -35,10 +35,11 @@ func (h *AdminHandler) ProductNew(w http.ResponseWriter, r *http.Request) {
} }
h.render(w, "admin_product_form.html", adminProductFormData{ h.render(w, "admin_product_form.html", adminProductFormData{
Title: "Новый товар", Title: "Новый товар",
Email: email, Email: email,
IsNew: true, IsNew: true,
Product: models.Product{IsActive: true, Category: "Разное"}, Categories: h.loadCategories(r.Context()),
Product: models.Product{IsActive: true},
}) })
} }
@@ -61,9 +62,10 @@ func (h *AdminHandler) ProductEdit(w http.ResponseWriter, r *http.Request) {
} }
h.render(w, "admin_product_form.html", adminProductFormData{ h.render(w, "admin_product_form.html", adminProductFormData{
Title: "Редактирование товара", Title: "Редактирование товара",
Email: email, Email: email,
Product: product, Product: product,
Categories: h.loadCategories(r.Context()),
}) })
} }
@@ -82,13 +84,27 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
productID, _ := strconv.Atoi(r.FormValue("id")) productID, _ := strconv.Atoi(r.FormValue("id"))
price, _ := strconv.ParseFloat(r.FormValue("price"), 64) price, _ := strconv.ParseFloat(r.FormValue("price"), 64)
salePrice, _ := strconv.ParseFloat(r.FormValue("sale_price"), 64)
categoryID, _ := strconv.Atoi(r.FormValue("category_id"))
var catID *int
var categoryName string
if categoryID > 0 {
catID = &categoryID
if cat, err := h.categories.GetByID(ctx, categoryID); err == nil {
categoryName = cat.Name
}
}
product := models.Product{ product := models.Product{
ID: productID, ID: productID,
Name: r.FormValue("name"), Name: r.FormValue("name"),
Description: r.FormValue("description"), Description: r.FormValue("description"),
Details: r.FormValue("details"), Details: r.FormValue("details"),
Price: price, Price: price,
Category: r.FormValue("category"), SalePrice: salePrice,
Category: categoryName,
CategoryID: catID,
IsActive: r.FormValue("is_active") == "on", IsActive: r.FormValue("is_active") == "on",
ImageURL: r.FormValue("image_url"), ImageURL: r.FormValue("image_url"),
} }
@@ -96,11 +112,12 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
if product.Name == "" || product.Price < 0 { if product.Name == "" || product.Price < 0 {
email, _ := h.requireAuth(w, r) email, _ := h.requireAuth(w, r)
h.render(w, "admin_product_form.html", adminProductFormData{ h.render(w, "admin_product_form.html", adminProductFormData{
Title: "Ошибка", Title: "Ошибка",
Email: email, Email: email,
Product: product, Product: product,
IsNew: isNew, Categories: h.loadCategories(ctx),
Error: "Заполните название и цену", IsNew: isNew,
Error: "Заполните название и цену",
}) })
return return
} }
+58 -6
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"fmt"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
@@ -9,10 +10,12 @@ import (
"shop/internal/auth" "shop/internal/auth"
"shop/internal/models" "shop/internal/models"
"shop/internal/repository" "shop/internal/repository"
"shop/internal/version"
) )
type CustomerHandler struct { type CustomerHandler struct {
products *repository.ProductRepository products *repository.ProductRepository
categories *repository.CategoryRepository
users *repository.UserRepository users *repository.UserRepository
cart *repository.CartRepository cart *repository.CartRepository
orders *repository.OrderRepository orders *repository.OrderRepository
@@ -26,6 +29,7 @@ type shopPage struct {
Title string Title string
User *models.User User *models.User
CartCount int CartCount int
Version string
Products interface{} Products interface{}
Categories interface{} Categories interface{}
Error string Error string
@@ -50,14 +54,26 @@ type checkoutPage struct {
type accountPage struct { type accountPage struct {
shopPage shopPage
Orders []models.Order Orders []models.Order
Name string Name string
Phone string Phone string
Address string Address string
} }
type productPage struct {
shopPage
Product models.Product
}
type categoryPage struct {
shopPage
Category models.Category
Products []models.Product
}
func NewCustomerHandler( func NewCustomerHandler(
products *repository.ProductRepository, products *repository.ProductRepository,
categories *repository.CategoryRepository,
users *repository.UserRepository, users *repository.UserRepository,
cart *repository.CartRepository, cart *repository.CartRepository,
orders *repository.OrderRepository, orders *repository.OrderRepository,
@@ -66,20 +82,21 @@ func NewCustomerHandler(
templates *template.Template, templates *template.Template,
) *CustomerHandler { ) *CustomerHandler {
return &CustomerHandler{ return &CustomerHandler{
products: products, users: users, cart: cart, orders: orders, stats: stats, products: products, categories: categories, users: users, cart: cart, orders: orders, stats: stats,
userSession: userSession, cartSession: cartSession, templates: templates, userSession: userSession, cartSession: cartSession, templates: templates,
} }
} }
func (h *CustomerHandler) render(w http.ResponseWriter, name string, data any) { func (h *CustomerHandler) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := h.templates.ExecuteTemplate(w, name, data); err != nil { if err := h.templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("render %s: %v", name, err) log.Printf("render %s: %v", name, err)
} }
} }
func (h *CustomerHandler) basePage(r *http.Request, w http.ResponseWriter, title string) shopPage { func (h *CustomerHandler) basePage(r *http.Request, w http.ResponseWriter, title string) shopPage {
p := shopPage{Title: title} p := shopPage{Title: title, Version: version.Version}
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok { if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
if u, err := h.users.GetByID(r.Context(), uid); err == nil { if u, err := h.users.GetByID(r.Context(), uid); err == nil {
p.User = &u p.User = &u
@@ -117,6 +134,40 @@ func (h *CustomerHandler) Home(w http.ResponseWriter, r *http.Request) {
h.render(w, "home.html", p) h.render(w, "home.html", p)
} }
func (h *CustomerHandler) ProductPage(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
product, err := h.products.GetActiveByID(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
p := productPage{
shopPage: h.basePage(r, w, product.Name),
Product: product,
}
h.render(w, "product.html", p)
}
func (h *CustomerHandler) CategoryPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
cat, err := h.categories.GetBySlug(r.Context(), slug)
if err != nil {
http.NotFound(w, r)
return
}
products, _ := h.products.ByCategoryID(r.Context(), cat.ID)
p := categoryPage{
shopPage: h.basePage(r, w, cat.Name),
Category: cat,
Products: products,
}
h.render(w, "category.html", p)
}
func (h *CustomerHandler) RegisterPage(w http.ResponseWriter, r *http.Request) { func (h *CustomerHandler) RegisterPage(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.UserIDFromSession(h.userSession, r); ok { if _, ok := auth.UserIDFromSession(h.userSession, r); ok {
http.Redirect(w, r, "/account", http.StatusSeeOther) http.Redirect(w, r, "/account", http.StatusSeeOther)
@@ -373,5 +424,6 @@ func (h *CustomerHandler) Account(w http.ResponseWriter, r *http.Request) {
func (h *CustomerHandler) Health(w http.ResponseWriter, r *http.Request) { func (h *CustomerHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`)) w.Header().Set("Cache-Control", "no-store")
fmt.Fprintf(w, `{"status":"ok","version":%q}`, version.Version)
} }
+22 -1
View File
@@ -7,6 +7,9 @@ import (
"log" "log"
"net/http" "net/http"
"regexp" "regexp"
"shop/internal/models"
"shop/internal/orderstatus"
) )
//go:embed templates/* //go:embed templates/*
@@ -21,9 +24,27 @@ func stripHTML(s string) string {
return tagRe.ReplaceAllString(s, "") return tagRe.ReplaceAllString(s, "")
} }
func effectivePrice(p models.Product) float64 { return p.EffectivePrice() }
func ParseTemplates() (*template.Template, error) { func ParseTemplates() (*template.Template, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"plain": stripHTML, "plain": stripHTML,
"effective": effectivePrice,
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
"orderLabel": orderstatus.Label,
"orderClass": orderstatus.Class,
"orderStep": orderstatus.Step,
"orderTimeline": func() []string { return orderstatus.Timeline },
"stepDone": func(status, step string) bool {
cur, s := orderstatus.Step(status), orderstatus.Step(step)
return cur > 0 && s > 0 && s < cur
},
"stepCurrent": func(status, step string) bool {
return orderstatus.Step(status) == orderstatus.Step(step)
},
"catSelected": func(catID int, productCatID *int) bool {
return productCatID != nil && *productCatID == catID
},
} }
return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html") return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
} }
+203
View File
@@ -250,7 +250,210 @@
margin: 0; margin: 0;
} }
/* Цены со скидкой */
.price-block {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.price-block__old {
text-decoration: line-through;
color: #9ca3af;
font-size: 0.9rem;
}
.price-block__sale,
.price-block__current {
font-size: 1.25rem;
font-weight: 700;
color: #ef4444;
}
.price-block--lg .price-block__sale,
.price-block--lg .price-block__current {
font-size: 2rem;
}
.price-block__badge {
background: #fef2f2;
color: #b91c1c;
padding: 4px 10px;
border-radius: 100px;
font-size: 0.85rem;
font-weight: 600;
}
/* Страница товара */
.breadcrumb {
display: flex;
gap: 8px;
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 24px;
flex-wrap: wrap;
}
.breadcrumb a {
color: #4f46e5;
text-decoration: none;
}
.product-detail {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
margin-bottom: 40px;
}
.product-detail__main {
width: 100%;
border-radius: 16px;
border: 1px solid #e5e7eb;
}
.product-detail__thumbs {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.product-detail__thumbs img {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #e5e7eb;
cursor: pointer;
}
.product-detail__cat {
color: #4f46e5;
text-decoration: none;
font-size: 0.875rem;
font-weight: 600;
}
.product-detail__info h1 {
font-size: 2rem;
margin: 12px 0 20px;
}
.product-detail__short {
color: #6b7280;
margin: 20px 0;
line-height: 1.6;
}
.product-detail__buy {
display: flex;
gap: 12px;
align-items: center;
}
.qty-input {
width: 72px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 10px;
font-size: 1rem;
}
.product-detail__html {
line-height: 1.7;
color: #374151;
}
.product-detail__html h1,
.product-detail__html h2,
.product-detail__html h3 {
margin: 1em 0 0.5em;
}
/* Статусы заказов */
.order-card__head {
justify-content: space-between;
}
.order-card__date {
display: block;
font-size: 0.8rem;
color: #9ca3af;
margin-top: 2px;
}
.order-status {
padding: 6px 14px;
border-radius: 100px;
font-size: 0.8rem;
font-weight: 600;
}
.status--new { background: #dbeafe; color: #1d4ed8; }
.status--confirmed { background: #e0e7ff; color: #4338ca; }
.status--processing { background: #ede9fe; color: #6d28d9; }
.status--shipped { background: #ffedd5; color: #c2410c; }
.status--delivered { background: #d1fae5; color: #065f46; }
.status--cancelled { background: #f3f4f6; color: #6b7280; }
.order-timeline {
display: flex;
gap: 4px;
margin: 16px 0;
padding: 16px 0;
overflow-x: auto;
}
.order-timeline__step {
flex: 1;
min-width: 80px;
text-align: center;
position: relative;
opacity: 0.35;
}
.order-timeline__step--done,
.order-timeline__step--current {
opacity: 1;
}
.order-timeline__dot {
display: block;
width: 12px;
height: 12px;
border-radius: 50%;
background: #d1d5db;
margin: 0 auto 8px;
}
.order-timeline__step--done .order-timeline__dot {
background: #10b981;
}
.order-timeline__step--current .order-timeline__dot {
background: #4f46e5;
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.2);
}
.order-timeline__label {
font-size: 0.7rem;
color: #6b7280;
line-height: 1.2;
}
.admin-form--row {
flex-direction: row;
flex-wrap: wrap;
align-items: flex-end;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.product-detail {
grid-template-columns: 1fr;
}
}
.cart-layout, .cart-layout,
.checkout-layout, .checkout-layout,
.account-layout { .account-layout {
+27 -2
View File
@@ -337,8 +337,33 @@ body {
transition: transform 0.3s; transition: transform 0.3s;
} }
.product-card:hover .product-card__image img { .product-card {
transform: scale(1.05); position: relative;
display: flex;
flex-direction: column;
}
.product-card__link {
text-decoration: none;
color: inherit;
flex: 1;
}
.product-card__cart {
padding: 0 20px 20px;
margin: 0;
}
.product-card__sale {
position: absolute;
top: 12px;
right: 12px;
background: #ef4444;
color: #fff;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 700;
} }
.product-card__badge { .product-card__badge {
@@ -0,0 +1,60 @@
{{define "admin_categories.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body class="admin-body">
{{template "admin_nav" .}}
<main class="admin-main container">
<h1 class="admin-page-title">Категории</h1>
<section class="admin-section">
<h2 class="admin-section__title">Добавить категорию</h2>
<form method="POST" action="/admin/categories" class="admin-form admin-form--row">
<label class="admin-form__label">
Название
<input type="text" name="name" class="admin-form__input" required>
</label>
<label class="admin-form__label">
Описание
<input type="text" name="description" class="admin-form__input">
</label>
<button type="submit" class="btn btn--primary">Добавить</button>
</form>
</section>
<section class="admin-section">
<table class="admin-table">
<thead>
<tr>
<th>Название</th>
<th>Slug</th>
<th>Товаров</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Categories}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td><a href="/category/{{.Slug}}" target="_blank">{{.Slug}}</a></td>
<td>{{.Count}}</td>
<td>
<form method="POST" action="/admin/categories/{{.ID}}/delete" onsubmit="return confirm('Удалить категорию?')">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
</main>
</body>
</html>
{{end}}
@@ -8,6 +8,7 @@
<nav class="admin-nav"> <nav class="admin-nav">
<a href="/admin/" class="admin-nav__link">Главная</a> <a href="/admin/" class="admin-nav__link">Главная</a>
<a href="/admin/products" class="admin-nav__link">Товары</a> <a href="/admin/products" class="admin-nav__link">Товары</a>
<a href="/admin/categories" class="admin-nav__link">Категории</a>
<a href="/admin/products/new" class="admin-nav__link admin-nav__link--accent">+ Добавить</a> <a href="/admin/products/new" class="admin-nav__link admin-nav__link--accent">+ Добавить</a>
</nav> </nav>
<div class="admin-header__user"> <div class="admin-header__user">
@@ -44,18 +44,21 @@
<input type="number" name="price" class="admin-form__input" value="{{printf "%.0f" .Product.Price}}" min="0" step="1" required> <input type="number" name="price" class="admin-form__input" value="{{printf "%.0f" .Product.Price}}" min="0" step="1" required>
</label> </label>
<label class="admin-form__label"> <label class="admin-form__label">
Категория Цена со скидкой (₽)
<input type="text" name="category" class="admin-form__input" value="{{.Product.Category}}" list="categories"> <input type="number" name="sale_price" class="admin-form__input" value="{{if .Product.HasSale}}{{printf "%.0f" .Product.SalePrice}}{{end}}" min="0" step="1" placeholder="Без скидки">
<datalist id="categories">
<option value="Электроника">
<option value="Одежда">
<option value="Дом и сад">
<option value="Аксессуары">
<option value="Разное">
</datalist>
</label> </label>
</div> </div>
<label class="admin-form__label">
Категория
<select name="category_id" class="admin-form__input">
<option value="">— без категории —</option>
{{range .Categories}}
<option value="{{.ID}}" {{if catSelected .ID $.Product.CategoryID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label class="admin-form__checkbox"> <label class="admin-form__checkbox">
<input type="checkbox" name="is_active" {{if .Product.IsActive}}checked{{end}}> <input type="checkbox" name="is_active" {{if .Product.IsActive}}checked{{end}}>
Показывать в каталоге Показывать в каталоге
+99
View File
@@ -0,0 +1,99 @@
{{define "product.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Product.Name}} — Shop</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<nav class="breadcrumb">
<a href="/">Главная</a>
{{if .Product.CategorySlug}}
<span>/</span>
<a href="/category/{{.Product.CategorySlug}}">{{.Product.Category}}</a>
{{end}}
<span>/</span>
<span>{{.Product.Name}}</span>
</nav>
<div class="product-detail">
<div class="product-detail__gallery">
<img src="{{.Product.ImageURL}}" alt="{{.Product.Name}}" class="product-detail__main">
{{if .Product.Images}}
<div class="product-detail__thumbs">
{{range .Product.Images}}
<img src="{{.FilePath}}" alt="">
{{end}}
</div>
{{end}}
</div>
<div class="product-detail__info">
{{if .Product.CategorySlug}}
<a href="/category/{{.Product.CategorySlug}}" class="product-detail__cat">{{.Product.Category}}</a>
{{end}}
<h1>{{.Product.Name}}</h1>
<div class="price-block price-block--lg">
{{if .Product.HasSale}}
<span class="price-block__old">{{printf "%.0f" .Product.Price}} ₽</span>
<span class="price-block__sale">{{printf "%.0f" .Product.SalePrice}} ₽</span>
<span class="price-block__badge">-{{.Product.DiscountPercent}}%</span>
{{else}}
<span class="price-block__current">{{printf "%.0f" .Product.Price}} ₽</span>
{{end}}
</div>
<p class="product-detail__short">{{plain .Product.Description}}</p>
<form method="POST" action="/cart/add" class="product-detail__buy">
<input type="hidden" name="product_id" value="{{.Product.ID}}">
<input type="number" name="quantity" value="1" min="1" max="99" class="qty-input">
<button type="submit" class="btn btn--primary btn--lg">Добавить в корзину</button>
</form>
</div>
</div>
{{if .Product.Details}}
<section class="product-detail__content shop-card">
<h2>Описание</h2>
<div class="product-detail__html">{{safeHTML .Product.Details}}</div>
</section>
{{end}}
</main>
</body>
</html>
{{end}}
{{define "category.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Category.Name}} — Shop</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<nav class="breadcrumb">
<a href="/">Главная</a>
<span>/</span>
<span>{{.Category.Name}}</span>
</nav>
<h1>{{.Category.Name}}</h1>
{{if .Category.Description}}<p class="shop-hint">{{.Category.Description}}</p>{{end}}
<div class="products">
{{range .Products}}
{{template "product_card" .}}
{{else}}
<p class="empty">В этой категории пока нет товаров.</p>
{{end}}
</div>
</main>
</body>
</html>
{{end}}
+3 -20
View File
@@ -43,7 +43,7 @@
<h2 class="section__title">Популярные категории</h2> <h2 class="section__title">Популярные категории</h2>
<div class="categories"> <div class="categories">
{{range .Categories}} {{range .Categories}}
<a href="#catalog" class="category-card"> <a href="/category/{{.Slug}}" class="category-card">
<span class="category-card__name">{{.Name}}</span> <span class="category-card__name">{{.Name}}</span>
<span class="category-card__count">{{.Count}} товаров</span> <span class="category-card__count">{{.Count}} товаров</span>
</a> </a>
@@ -60,24 +60,7 @@
</div> </div>
<div class="products"> <div class="products">
{{range .Products}} {{range .Products}}
<article class="product-card"> {{template "product_card" .}}
<div class="product-card__image">
<img src="{{.ImageURL}}" alt="{{.Name}}" loading="lazy">
<span class="product-card__badge">{{.Category}}</span>
</div>
<div class="product-card__body">
<h3 class="product-card__title">{{.Name}}</h3>
<p class="product-card__desc">{{plain .Description}}</p>
<div class="product-card__footer">
<span class="product-card__price">{{printf "%.0f" .Price}} ₽</span>
<form method="POST" action="/cart/add">
<input type="hidden" name="product_id" value="{{.ID}}">
<input type="hidden" name="quantity" value="1">
<button type="submit" class="btn btn--primary btn--sm">В корзину</button>
</form>
</div>
</div>
</article>
{{else}} {{else}}
<p class="empty">Товары скоро появятся в каталоге.</p> <p class="empty">Товары скоро появятся в каталоге.</p>
{{end}} {{end}}
@@ -118,7 +101,7 @@
<div class="footer__brand"> <div class="footer__brand">
<span class="logo__icon"></span> <span class="logo__icon"></span>
<span class="logo__text">Shop</span> <span class="logo__text">Shop</span>
<p class="footer__copy">© 2026 Shop. Все права защищены.</p> <p class="footer__copy">© 2026 Shop · v{{.Version}}</p>
</div> </div>
<div class="footer__links"> <div class="footer__links">
<a href="/account">Личный кабинет</a> <a href="/account">Личный кабинет</a>
@@ -0,0 +1,30 @@
{{define "product_card"}}
<article class="product-card">
<a href="/product/{{.ID}}" class="product-card__link">
<div class="product-card__image">
<img src="{{.ImageURL}}" alt="{{.Name}}" loading="lazy">
{{if .HasSale}}<span class="product-card__sale">-{{.DiscountPercent}}%</span>{{end}}
<span class="product-card__badge">{{.Category}}</span>
</div>
<div class="product-card__body">
<h3 class="product-card__title">{{.Name}}</h3>
<p class="product-card__desc">{{plain .Description}}</p>
<div class="product-card__footer">
<div class="price-block">
{{if .HasSale}}
<span class="price-block__old">{{printf "%.0f" .Price}} ₽</span>
<span class="product-card__price price-block__sale">{{printf "%.0f" .SalePrice}} ₽</span>
{{else}}
<span class="product-card__price">{{printf "%.0f" .Price}} ₽</span>
{{end}}
</div>
</div>
</div>
</a>
<form method="POST" action="/cart/add" class="product-card__cart">
<input type="hidden" name="product_id" value="{{.ID}}">
<input type="hidden" name="quantity" value="1">
<button type="submit" class="btn btn--primary btn--sm">В корзину</button>
</form>
</article>
{{end}}
+1 -1
View File
@@ -6,9 +6,9 @@
<span class="logo__text">Shop</span> <span class="logo__text">Shop</span>
</a> </a>
<nav class="nav"> <nav class="nav">
<a href="/#catalog" class="nav__link">Каталог</a>
<a href="/#catalog" class="nav__link">Каталог</a> <a href="/#catalog" class="nav__link">Каталог</a>
<a href="/#categories" class="nav__link">Категории</a> <a href="/#categories" class="nav__link">Категории</a>
<a href="/#delivery" class="nav__link">Доставка</a>
</nav> </nav>
<div class="header__actions"> <div class="header__actions">
{{if .User}} {{if .User}}
+26 -5
View File
@@ -19,8 +19,15 @@
<div class="cart-item"> <div class="cart-item">
<img src="{{.Product.ImageURL}}" alt="{{.Product.Name}}" class="cart-item__img"> <img src="{{.Product.ImageURL}}" alt="{{.Product.Name}}" class="cart-item__img">
<div class="cart-item__info"> <div class="cart-item__info">
<h3>{{.Product.Name}}</h3> <h3><a href="/product/{{.ProductID}}">{{.Product.Name}}</a></h3>
<p class="cart-item__price">{{printf "%.0f" .Product.Price}} ₽</p> <div class="price-block">
{{if .Product.HasSale}}
<span class="price-block__old">{{printf "%.0f" .Product.Price}} ₽</span>
<span class="price-block__sale">{{printf "%.0f" .Product.SalePrice}} ₽</span>
{{else}}
<span class="cart-item__price">{{printf "%.0f" .Product.Price}} ₽</span>
{{end}}
</div>
</div> </div>
<form method="POST" action="/cart/update" class="cart-item__qty"> <form method="POST" action="/cart/update" class="cart-item__qty">
<input type="hidden" name="product_id" value="{{.ProductID}}"> <input type="hidden" name="product_id" value="{{.ProductID}}">
@@ -164,10 +171,24 @@
{{range .Orders}} {{range .Orders}}
<div class="order-card"> <div class="order-card">
<div class="order-card__head"> <div class="order-card__head">
<strong>Заказ #{{.ID}}</strong> <div>
<span>{{.CreatedAt.Format "02.01.2006 15:04"}}</span> <strong>Заказ #{{.ID}}</strong>
<span class="admin-badge admin-badge--ok">{{.Status}}</span> <span class="order-card__date">{{.CreatedAt.Format "02.01.2006 15:04"}}</span>
</div>
<span class="order-status {{orderClass .Status}}">{{orderLabel .Status}}</span>
</div> </div>
{{if ne .Status "cancelled"}}
<div class="order-timeline">
{{range orderTimeline}}
<div class="order-timeline__step {{if stepCurrent $.Status .}}order-timeline__step--current{{end}} {{if stepDone $.Status .}}order-timeline__step--done{{end}}">
<span class="order-timeline__dot"></span>
<span class="order-timeline__label">{{orderLabel .}}</span>
</div>
{{end}}
</div>
{{end}}
<ul class="order-card__items"> <ul class="order-card__items">
{{range .Items}} {{range .Items}}
<li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li> <li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li>
+38 -14
View File
@@ -3,17 +3,38 @@ package models
import "time" import "time"
type Product struct { type Product struct {
ID int ID int
Name string Name string
Description string Description string
Details string Details string
Price float64 Price float64
ImageURL string SalePrice float64
Category string ImageURL string
IsActive bool Category string
CreatedAt time.Time CategoryID *int
UpdatedAt time.Time CategorySlug string
Images []ProductImage IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
Images []ProductImage
}
func (p Product) EffectivePrice() float64 {
if p.SalePrice > 0 && p.SalePrice < p.Price {
return p.SalePrice
}
return p.Price
}
func (p Product) HasSale() bool {
return p.SalePrice > 0 && p.SalePrice < p.Price
}
func (p Product) DiscountPercent() int {
if !p.HasSale() || p.Price <= 0 {
return 0
}
return int((1 - p.SalePrice/p.Price) * 100)
} }
type ProductImage struct { type ProductImage struct {
@@ -26,9 +47,12 @@ type ProductImage struct {
} }
type Category struct { type Category struct {
Name string ID int
Slug string Name string
Count int Slug string
Description string
SortOrder int
Count int
} }
type SiteStats struct { type SiteStats struct {
+37
View File
@@ -0,0 +1,37 @@
package orderstatus
type Info struct {
Label string
Class string
Step int
}
var statuses = map[string]Info{
"new": {Label: "Новый", Class: "status--new", Step: 1},
"confirmed": {Label: "Подтверждён", Class: "status--confirmed", Step: 2},
"processing": {Label: "В обработке", Class: "status--processing", Step: 3},
"shipped": {Label: "Отправлен", Class: "status--shipped", Step: 4},
"delivered": {Label: "Доставлен", Class: "status--delivered", Step: 5},
"cancelled": {Label: "Отменён", Class: "status--cancelled", Step: 0},
}
var Timeline = []string{"new", "confirmed", "processing", "shipped", "delivered"}
func Get(code string) Info {
if s, ok := statuses[code]; ok {
return s
}
return Info{Label: code, Class: "status--new", Step: 1}
}
func Label(code string) string {
return Get(code).Label
}
func Class(code string) string {
return Get(code).Class
}
func Step(code string) int {
return Get(code).Step
}
+15 -3
View File
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"database/sql"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -138,9 +139,12 @@ func (r *CartRepository) ItemCount(ctx context.Context, cartID int) (int, error)
func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartItem, error) { func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartItem, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT ci.id, ci.cart_id, ci.product_id, ci.quantity, SELECT ci.id, ci.cart_id, ci.product_id, ci.quantity,
p.id, p.name, p.description, p.details, p.price, p.image_url, p.category, p.is_active, p.created_at, p.updated_at p.id, p.name, p.description, p.details, p.price, COALESCE(p.sale_price, 0),
p.image_url, COALESCE(c.name, p.category, 'Разное'), p.category_id,
COALESCE(c.slug, ''), p.is_active, p.created_at, p.updated_at
FROM cart_items ci FROM cart_items ci
JOIN products p ON p.id = ci.product_id JOIN products p ON p.id = ci.product_id
LEFT JOIN categories c ON c.id = p.category_id
WHERE ci.cart_id = $1 AND p.is_active = true WHERE ci.cart_id = $1 AND p.is_active = true
ORDER BY ci.id ORDER BY ci.id
`, cartID) `, cartID)
@@ -153,12 +157,18 @@ func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartIt
for rows.Next() { for rows.Next() {
var ci models.CartItem var ci models.CartItem
var p models.Product var p models.Product
var catID sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
&ci.ID, &ci.CartID, &ci.ProductID, &ci.Quantity, &ci.ID, &ci.CartID, &ci.ProductID, &ci.Quantity,
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt, &p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
&p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if catID.Valid {
id := int(catID.Int64)
p.CategoryID = &id
}
ci.Product = p ci.Product = p
items = append(items, ci) items = append(items, ci)
} }
@@ -168,7 +178,9 @@ func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartIt
func (r *CartRepository) Total(ctx context.Context, cartID int) (float64, error) { func (r *CartRepository) Total(ctx context.Context, cartID int) (float64, error) {
var total float64 var total float64
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
SELECT COALESCE(SUM(ci.quantity * p.price), 0) SELECT COALESCE(SUM(ci.quantity * CASE
WHEN p.sale_price > 0 AND p.sale_price < p.price THEN p.sale_price
ELSE p.price END), 0)
FROM cart_items ci JOIN products p ON p.id = ci.product_id FROM cart_items ci JOIN products p ON p.id = ci.product_id
WHERE ci.cart_id = $1 WHERE ci.cart_id = $1
`, cartID).Scan(&total) `, cartID).Scan(&total)
+125
View File
@@ -0,0 +1,125 @@
package repository
import (
"context"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type CategoryRepository struct {
pool *pgxpool.Pool
}
func NewCategoryRepository(pool *pgxpool.Pool) *CategoryRepository {
return &CategoryRepository{pool: pool}
}
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
func Slugify(name string) string {
translit := map[rune]string{
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e",
'ж': "zh", 'з': "z", 'и': "i", 'й': "y", 'к': "k", 'л': "l", 'м': "m",
'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t", 'у': "u",
'ф': "f", 'х': "h", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "sch",
'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya",
}
var b strings.Builder
for _, r := range strings.ToLower(name) {
if t, ok := translit[r]; ok {
b.WriteString(t)
} else if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else if r == ' ' || r == '-' {
b.WriteRune('-')
}
}
s := slugRe.ReplaceAllString(b.String(), "-")
return strings.Trim(s, "-")
}
func (r *CategoryRepository) All(ctx context.Context) ([]models.Category, error) {
rows, err := r.pool.Query(ctx, `
SELECT c.id, c.name, c.slug, c.description, c.sort_order,
COUNT(p.id) FILTER (WHERE p.is_active = true) AS cnt
FROM categories c
LEFT JOIN products p ON p.category_id = c.id
GROUP BY c.id
ORDER BY c.sort_order, c.name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.Category
for rows.Next() {
var c models.Category
if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder, &c.Count); err != nil {
return nil, err
}
list = append(list, c)
}
return list, rows.Err()
}
func (r *CategoryRepository) GetBySlug(ctx context.Context, slug string) (models.Category, error) {
var c models.Category
err := r.pool.QueryRow(ctx, `
SELECT id, name, slug, description, sort_order FROM categories WHERE slug = $1
`, slug).Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder)
return c, err
}
func (r *CategoryRepository) GetByID(ctx context.Context, id int) (models.Category, error) {
var c models.Category
err := r.pool.QueryRow(ctx, `
SELECT id, name, slug, description, sort_order FROM categories WHERE id = $1
`, id).Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder)
return c, err
}
func (r *CategoryRepository) Create(ctx context.Context, name, description string) (models.Category, error) {
slug := Slugify(name)
var c models.Category
err := r.pool.QueryRow(ctx, `
INSERT INTO categories (name, slug, description)
VALUES ($1, $2, $3)
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id, name, slug, description, sort_order
`, name, slug, description).Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder)
return c, err
}
func (r *CategoryRepository) Update(ctx context.Context, c *models.Category) error {
tag, err := r.pool.Exec(ctx, `
UPDATE categories SET name = $1, slug = $2, description = $3, sort_order = $4 WHERE id = $5
`, c.Name, c.Slug, c.Description, c.SortOrder, c.ID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (r *CategoryRepository) Delete(ctx context.Context, id int) error {
_, err := r.pool.Exec(ctx, `UPDATE products SET category_id = NULL WHERE category_id = $1`, id)
if err != nil {
return err
}
tag, err := r.pool.Exec(ctx, `DELETE FROM categories WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
+1 -1
View File
@@ -37,7 +37,7 @@ func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
INSERT INTO order_items (order_id, product_id, product_name, price, quantity) INSERT INTO order_items (order_id, product_id, product_name, price, quantity)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
`, order.ID, item.ProductID, item.Product.Name, item.Product.Price, item.Quantity) `, order.ID, item.ProductID, item.Product.Name, item.Product.EffectivePrice(), item.Quantity)
if err != nil { if err != nil {
return err return err
} }
+69 -44
View File
@@ -2,8 +2,8 @@ package repository
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -19,25 +19,32 @@ func NewProductRepository(pool *pgxpool.Pool) *ProductRepository {
return &ProductRepository{pool: pool} return &ProductRepository{pool: pool}
} }
const productSelect = `
SELECT p.id, p.name, p.description, p.details, p.price, COALESCE(p.sale_price, 0),
p.image_url, COALESCE(c.name, p.category, 'Разное'), p.category_id,
COALESCE(c.slug, ''), p.is_active, p.created_at, p.updated_at
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
`
func (r *ProductRepository) scanProduct(row pgx.Row) (models.Product, error) { func (r *ProductRepository) scanProduct(row pgx.Row) (models.Product, error) {
var p models.Product var p models.Product
var catID sql.NullInt64
err := row.Scan( err := row.Scan(
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
&p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt, &p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
) )
if catID.Valid {
id := int(catID.Int64)
p.CategoryID = &id
}
return p, err return p, err
} }
const productColumns = `id, name, description, details, price, image_url, category, is_active, created_at, updated_at`
func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.Product, error) { func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.Product, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, productSelect+`
SELECT `+productColumns+` WHERE p.is_active = true
FROM products ORDER BY p.created_at DESC LIMIT $1`, limit)
WHERE is_active = true
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -46,11 +53,18 @@ func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.P
} }
func (r *ProductRepository) All(ctx context.Context) ([]models.Product, error) { func (r *ProductRepository) All(ctx context.Context) ([]models.Product, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, productSelect+` ORDER BY p.created_at DESC`)
SELECT `+productColumns+` if err != nil {
FROM products return nil, err
ORDER BY created_at DESC }
`) defer rows.Close()
return r.collectProducts(rows)
}
func (r *ProductRepository) ByCategoryID(ctx context.Context, categoryID int) ([]models.Product, error) {
rows, err := r.pool.Query(ctx, productSelect+`
WHERE p.is_active = true AND p.category_id = $1
ORDER BY p.created_at DESC`, categoryID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -59,7 +73,17 @@ func (r *ProductRepository) All(ctx context.Context) ([]models.Product, error) {
} }
func (r *ProductRepository) GetByID(ctx context.Context, id int) (models.Product, error) { func (r *ProductRepository) GetByID(ctx context.Context, id int) (models.Product, error) {
row := r.pool.QueryRow(ctx, `SELECT `+productColumns+` FROM products WHERE id = $1`, id) row := r.pool.QueryRow(ctx, productSelect+` WHERE p.id = $1`, id)
p, err := r.scanProduct(row)
if err != nil {
return models.Product{}, err
}
p.Images, err = r.ImagesByProduct(ctx, id)
return p, err
}
func (r *ProductRepository) GetActiveByID(ctx context.Context, id int) (models.Product, error) {
row := r.pool.QueryRow(ctx, productSelect+` WHERE p.id = $1 AND p.is_active = true`, id)
p, err := r.scanProduct(row) p, err := r.scanProduct(row)
if err != nil { if err != nil {
return models.Product{}, err return models.Product{}, err
@@ -70,20 +94,20 @@ func (r *ProductRepository) GetByID(ctx context.Context, id int) (models.Product
func (r *ProductRepository) Create(ctx context.Context, p *models.Product) error { func (r *ProductRepository) Create(ctx context.Context, p *models.Product) error {
return r.pool.QueryRow(ctx, ` return r.pool.QueryRow(ctx, `
INSERT INTO products (name, description, details, price, image_url, category, is_active) INSERT INTO products (name, description, details, price, sale_price, image_url, category, category_id, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, NULLIF($5, 0), $6, $7, $8, $9)
RETURNING id, created_at, updated_at RETURNING id, created_at, updated_at
`, p.Name, p.Description, p.Details, p.Price, p.ImageURL, p.Category, p.IsActive, `, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive,
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) ).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
} }
func (r *ProductRepository) Update(ctx context.Context, p *models.Product) error { func (r *ProductRepository) Update(ctx context.Context, p *models.Product) error {
tag, err := r.pool.Exec(ctx, ` tag, err := r.pool.Exec(ctx, `
UPDATE products UPDATE products SET name = $1, description = $2, details = $3, price = $4,
SET name = $1, description = $2, details = $3, price = $4, sale_price = NULLIF($5, 0), image_url = $6, category = $7, category_id = $8,
image_url = $5, category = $6, is_active = $7, updated_at = NOW() is_active = $9, updated_at = NOW()
WHERE id = $8 WHERE id = $10
`, p.Name, p.Description, p.Details, p.Price, p.ImageURL, p.Category, p.IsActive, p.ID) `, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive, p.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -106,10 +130,12 @@ func (r *ProductRepository) Delete(ctx context.Context, id int) error {
func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category, error) { func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT category, COUNT(*) AS cnt SELECT c.id, c.name, c.slug, c.description, c.sort_order,
FROM products COUNT(p.id) FILTER (WHERE p.is_active = true)
GROUP BY category FROM categories c
ORDER BY cnt DESC LEFT JOIN products p ON p.category_id = c.id
GROUP BY c.id
ORDER BY c.sort_order, c.name
`) `)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -119,10 +145,9 @@ func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category,
var categories []models.Category var categories []models.Category
for rows.Next() { for rows.Next() {
var c models.Category var c models.Category
if err := rows.Scan(&c.Name, &c.Count); err != nil { if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder, &c.Count); err != nil {
return nil, err return nil, err
} }
c.Slug = c.Name
categories = append(categories, c) categories = append(categories, c)
} }
return categories, rows.Err() return categories, rows.Err()
@@ -131,8 +156,7 @@ func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category,
func (r *ProductRepository) ImagesByProduct(ctx context.Context, productID int) ([]models.ProductImage, error) { func (r *ProductRepository) ImagesByProduct(ctx context.Context, productID int) ([]models.ProductImage, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT id, product_id, file_path, is_primary, sort_order, created_at SELECT id, product_id, file_path, is_primary, sort_order, created_at
FROM product_images FROM product_images WHERE product_id = $1
WHERE product_id = $1
ORDER BY is_primary DESC, sort_order ASC, id ASC ORDER BY is_primary DESC, sort_order ASC, id ASC
`, productID) `, productID)
if err != nil { if err != nil {
@@ -154,21 +178,17 @@ func (r *ProductRepository) ImagesByProduct(ctx context.Context, productID int)
func (r *ProductRepository) AddImage(ctx context.Context, img *models.ProductImage) error { func (r *ProductRepository) AddImage(ctx context.Context, img *models.ProductImage) error {
return r.pool.QueryRow(ctx, ` return r.pool.QueryRow(ctx, `
INSERT INTO product_images (product_id, file_path, is_primary, sort_order) INSERT INTO product_images (product_id, file_path, is_primary, sort_order)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4) RETURNING id, created_at
RETURNING id, created_at `, img.ProductID, img.FilePath, img.IsPrimary, img.SortOrder).Scan(&img.ID, &img.CreatedAt)
`, img.ProductID, img.FilePath, img.IsPrimary, img.SortOrder,
).Scan(&img.ID, &img.CreatedAt)
} }
func (r *ProductRepository) DeleteImage(ctx context.Context, imageID, productID int) (string, error) { func (r *ProductRepository) DeleteImage(ctx context.Context, imageID, productID int) (string, error) {
var path string var path string
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
DELETE FROM product_images DELETE FROM product_images WHERE id = $1 AND product_id = $2 RETURNING file_path
WHERE id = $1 AND product_id = $2
RETURNING file_path
`, imageID, productID).Scan(&path) `, imageID, productID).Scan(&path)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return "", fmt.Errorf("image not found") return "", errors.New("image not found")
} }
return path, err return path, err
} }
@@ -177,12 +197,17 @@ func (r *ProductRepository) collectProducts(rows pgx.Rows) ([]models.Product, er
var products []models.Product var products []models.Product
for rows.Next() { for rows.Next() {
var p models.Product var p models.Product
var catID sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
&p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt, &p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if catID.Valid {
id := int(catID.Int64)
p.CategoryID = &id
}
products = append(products, p) products = append(products, p)
} }
return products, rows.Err() return products, rows.Err()
+4
View File
@@ -0,0 +1,4 @@
package version
// Version задаётся при сборке: -ldflags "-X shop/internal/version.Version=..."
var Version = "dev"
+24
View File
@@ -0,0 +1,24 @@
-- Категории и цена со скидкой
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INT REFERENCES categories(id) ON DELETE SET NULL;
ALTER TABLE products ADD COLUMN IF NOT EXISTS sale_price NUMERIC(10, 2);
INSERT INTO categories (name, slug, sort_order) VALUES
('Электроника', 'elektronika', 1),
('Одежда', 'odezhda', 2),
('Дом и сад', 'dom-i-sad', 3),
('Аксессуары', 'aksessuary', 4),
('Разное', 'raznoe', 5)
ON CONFLICT (slug) DO NOTHING;
UPDATE products p SET category_id = c.id
FROM categories c WHERE p.category = c.name AND p.category_id IS NULL;
+5 -1
View File
@@ -25,8 +25,12 @@ if [[ ! -f .env ]]; then
echo "Проверьте ADMIN_EMAIL и DOMAIN перед продакшеном." echo "Проверьте ADMIN_EMAIL и DOMAIN перед продакшеном."
fi fi
export APP_VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
docker compose pull --ignore-buildable 2>/dev/null || true docker compose pull --ignore-buildable 2>/dev/null || true
docker compose up --build -d docker compose build app
docker compose up -d --force-recreate
echo "" echo ""
echo "Сервисы запущены." echo "Сервисы запущены."
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Полная пересборка и перезапуск
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
echo "=== Shop redeploy ==="
if [[ -d .git ]]; then
echo "Обновление кода из git (сброс локальных правок скриптов)..."
git fetch origin main
git reset --hard origin/main
export APP_VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)
echo "Git: $(git log -1 --oneline)"
else
export APP_VERSION="manual"
fi
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "Version: ${APP_VERSION}"
chmod +x scripts/*.sh 2>/dev/null || true
echo ""
echo "Останавливаем app..."
docker compose stop app 2>/dev/null || true
echo "Пересборка образа (без кэша)..."
docker compose build --no-cache app
echo "Запуск контейнеров..."
docker compose up -d --force-recreate app caddy
echo ""
echo "Ожидание запуска..."
sleep 3
echo ""
echo "Проверка версии:"
curl -sf "http://127.0.0.1/health" 2>/dev/null || curl -sf "http://localhost/health" || docker compose exec -T app wget -qO- http://127.0.0.1:8080/health
echo ""
echo ""
IP=$(hostname -I | awk '{print $1}')
echo "Готово: http://${IP}"
echo "Логи: docker compose logs -f app"
+14 -2
View File
@@ -4,8 +4,20 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}" cd "${ROOT_DIR}"
git pull origin main git fetch origin main
docker compose up --build -d git reset --hard origin/main
chmod +x scripts/*.sh 2>/dev/null || true
export APP_VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "Обновление до ${APP_VERSION}..."
docker compose build app
docker compose up -d --force-recreate --no-deps app caddy
docker image prune -f docker image prune -f
sleep 2
echo "Версия на сервере:"
curl -sf http://127.0.0.1/health || curl -sf http://localhost/health || true
echo ""
echo "Обновление завершено." echo "Обновление завершено."