Compare commits
3
Commits
v0.20-beta
...
v0.30-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e000264bb1 | ||
|
|
532351e1c9 | ||
|
|
306002bfa1 |
+5
-1
@@ -9,7 +9,11 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
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
|
||||
FROM alpine:3.21
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
# 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 |
|
||||
| Прокси / SSL | Caddy 2 |
|
||||
| ОС | Ubuntu 22.04 / 24.04 |
|
||||
| Контейнеризация | Docker Compose |
|
||||
|
||||
## Быстрый старт на Ubuntu
|
||||
## Быстрый старт (Ubuntu)
|
||||
|
||||
```bash
|
||||
# Клонировать репозиторий
|
||||
git clone https://git.evilfox.cc/test2/shop-go.git /opt/shop
|
||||
cd /opt/shop
|
||||
|
||||
# 1. Установить Docker (один раз, с sudo)
|
||||
sudo ./scripts/install-ubuntu.sh
|
||||
|
||||
# Перелогиниться или:
|
||||
newgrp docker
|
||||
|
||||
# 2. Запустить магазин
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/deploy.sh
|
||||
sudo bash setup.sh
|
||||
```
|
||||
|
||||
Откройте в браузере:
|
||||
- `http://IP_СЕРВЕРА` — магазин
|
||||
- `http://IP_СЕРВЕРА/admin/login` — админка
|
||||
|
||||
Или через Make:
|
||||
Или обновление существующего:
|
||||
|
||||
```bash
|
||||
make install # sudo, установка Docker
|
||||
make deploy # первый запуск
|
||||
make logs # просмотр логов
|
||||
cd /opt/shop
|
||||
bash scripts/redeploy.sh
|
||||
```
|
||||
|
||||
## Продакшен с доменом и SSL
|
||||
|
||||
1. Настройте DNS: A-запись домена → IP сервера Ubuntu
|
||||
2. Откройте порты 80 и 443 (скрипт `install-ubuntu.sh` настраивает ufw)
|
||||
3. Включите SSL:
|
||||
Проверка версии:
|
||||
|
||||
```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
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now shop
|
||||
```
|
||||
| Код | Отображение |
|
||||
|-----|-------------|
|
||||
| `new` | Новый |
|
||||
| `confirmed` | Подтверждён |
|
||||
| `processing` | В обработке |
|
||||
| `shipped` | Отправлен |
|
||||
| `delivered` | Доставлен |
|
||||
| `cancelled` | Отменён |
|
||||
|
||||
В личном кабинете — цветной бейдж и таймлайн прогресса.
|
||||
|
||||
## Админ
|
||||
|
||||
Администратор создаётся автоматически при старте из `.env`:
|
||||
|
||||
```env
|
||||
ADMIN_EMAIL=admin@shop.local
|
||||
ADMIN_PASSWORD=change_me
|
||||
SESSION_SECRET=your_random_secret_key_here
|
||||
```
|
||||
|
||||
Скрипт `deploy.sh` генерирует пароли автоматически при первом запуске.
|
||||
|
||||
Вход: `/admin/login`
|
||||
|
||||
При изменении `ADMIN_PASSWORD` пароль обновится в БД при следующем запуске.
|
||||
|
||||
## Сервисы
|
||||
|
||||
| Сервис | Порт | Описание |
|
||||
|--------|------|----------|
|
||||
| Caddy | 80, 443 | Обратный прокси, SSL |
|
||||
| App | 8080 (внутри) | Go-приложение |
|
||||
| PostgreSQL | 5432 (внутри) | База данных |
|
||||
|
||||
## Обновление на сервере
|
||||
## SSL
|
||||
|
||||
```bash
|
||||
./scripts/update.sh
|
||||
# или
|
||||
make update
|
||||
./scripts/enable-ssl.sh shop.example.com admin@example.com
|
||||
```
|
||||
|
||||
## Локальная разработка (Ubuntu)
|
||||
## Обновление
|
||||
|
||||
```bash
|
||||
# Только PostgreSQL в Docker
|
||||
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
|
||||
bash scripts/redeploy.sh
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
Скрипт делает `git reset --hard`, пересборку Docker без кэша и проверку `/health`.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
shop/
|
||||
├── cmd/shop/main.go
|
||||
├── cmd/shop/ # Точка входа
|
||||
├── internal/
|
||||
│ ├── handlers/ # HTTP, шаблоны, статика
|
||||
│ ├── repository/ # БД
|
||||
│ ├── orderstatus/ # Статусы заказов
|
||||
│ └── ...
|
||||
├── migrations/
|
||||
├── scripts/
|
||||
│ ├── install-ubuntu.sh # установка Docker + ufw
|
||||
│ ├── deploy.sh # первый запуск
|
||||
│ ├── enable-ssl.sh # SSL для домена
|
||||
│ └── update.sh # обновление
|
||||
├── systemd/shop.service # автозапуск
|
||||
├── Caddyfile # HTTP (IP / localhost)
|
||||
├── Caddyfile.prod # HTTPS (домен)
|
||||
├── docker-compose.yml
|
||||
├── Makefile
|
||||
└── Dockerfile
|
||||
│ ├── deploy.sh
|
||||
│ ├── redeploy.sh # Полное обновление
|
||||
│ └── update.sh
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## API
|
||||
## Требования
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|----------|
|
||||
| GET | `/` | Главная страница |
|
||||
| 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
|
||||
- Ubuntu 22.04 / 24.04
|
||||
- 1 CPU, 1 GB RAM
|
||||
- Порты 22, 80, 443
|
||||
|
||||
+10
-3
@@ -16,6 +16,7 @@ import (
|
||||
"shop/internal/middleware"
|
||||
"shop/internal/repository"
|
||||
"shop/internal/upload"
|
||||
"shop/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -33,6 +34,7 @@ func main() {
|
||||
}
|
||||
|
||||
productRepo := repository.NewProductRepository(pool)
|
||||
categoryRepo := repository.NewCategoryRepository(pool)
|
||||
adminRepo := repository.NewAdminRepository(pool)
|
||||
statsRepo := repository.NewStatsRepository(pool)
|
||||
userRepo := repository.NewUserRepository(pool)
|
||||
@@ -61,10 +63,10 @@ func main() {
|
||||
cartSession := auth.NewSession(cfg.SessionSecret, "shop_cart_key", "/")
|
||||
|
||||
customer := handlers.NewCustomerHandler(
|
||||
productRepo, userRepo, cartRepo, orderRepo, statsRepo,
|
||||
productRepo, categoryRepo, userRepo, cartRepo, orderRepo, statsRepo,
|
||||
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.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler()))
|
||||
@@ -72,6 +74,8 @@ func main() {
|
||||
mux.HandleFunc("GET /health", customer.Health)
|
||||
|
||||
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("POST /register", customer.Register)
|
||||
mux.HandleFunc("GET /login", customer.LoginPage)
|
||||
@@ -96,6 +100,9 @@ func main() {
|
||||
mux.HandleFunc("GET /admin/products/{id}/edit", admin.ProductEdit)
|
||||
mux.HandleFunc("POST /admin/products/{id}/delete", admin.ProductDelete)
|
||||
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")
|
||||
srv := &http.Server{
|
||||
@@ -107,7 +114,7 @@ func main() {
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
- ./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/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:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"]
|
||||
interval: 5s
|
||||
@@ -26,6 +27,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
BUILD_TIME: ${BUILD_TIME:-}
|
||||
container_name: shop-app
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
@@ -34,6 +38,7 @@ services:
|
||||
DB_PORT: "5432"
|
||||
APP_PORT: "8080"
|
||||
UPLOAD_DIR: /app/data/uploads
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
volumes:
|
||||
- uploads:/app/data/uploads
|
||||
depends_on:
|
||||
|
||||
@@ -77,6 +77,25 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
quantity INT NOT NULL CHECK (quantity > 0)
|
||||
)`,
|
||||
`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 {
|
||||
|
||||
+27
-17
@@ -13,12 +13,13 @@ import (
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
repo *repository.AdminRepository
|
||||
products *repository.ProductRepository
|
||||
stats *repository.StatsRepository
|
||||
storage *upload.Storage
|
||||
session *auth.Session
|
||||
templates *template.Template
|
||||
repo *repository.AdminRepository
|
||||
products *repository.ProductRepository
|
||||
categories *repository.CategoryRepository
|
||||
stats *repository.StatsRepository
|
||||
storage *upload.Storage
|
||||
session *auth.Session
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
type adminLayoutData struct {
|
||||
@@ -52,28 +53,37 @@ type adminProductsData struct {
|
||||
}
|
||||
|
||||
type adminProductFormData struct {
|
||||
Title string
|
||||
Email string
|
||||
Product models.Product
|
||||
IsNew bool
|
||||
Error string
|
||||
Title string
|
||||
Email string
|
||||
Product models.Product
|
||||
Categories []models.Category
|
||||
IsNew bool
|
||||
Error string
|
||||
}
|
||||
|
||||
type adminCategoriesData struct {
|
||||
Title string
|
||||
Email string
|
||||
Categories []models.Category
|
||||
}
|
||||
|
||||
func NewAdminHandler(
|
||||
adminRepo *repository.AdminRepository,
|
||||
productRepo *repository.ProductRepository,
|
||||
categoryRepo *repository.CategoryRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
storage *upload.Storage,
|
||||
session *auth.Session,
|
||||
templates *template.Template,
|
||||
) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
repo: adminRepo,
|
||||
products: productRepo,
|
||||
stats: statsRepo,
|
||||
storage: storage,
|
||||
session: session,
|
||||
templates: templates,
|
||||
repo: adminRepo,
|
||||
products: productRepo,
|
||||
categories: categoryRepo,
|
||||
stats: statsRepo,
|
||||
storage: storage,
|
||||
session: session,
|
||||
templates: templates,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -35,10 +35,11 @@ func (h *AdminHandler) ProductNew(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
h.render(w, "admin_product_form.html", adminProductFormData{
|
||||
Title: "Новый товар",
|
||||
Email: email,
|
||||
IsNew: true,
|
||||
Product: models.Product{IsActive: true, Category: "Разное"},
|
||||
Title: "Новый товар",
|
||||
Email: email,
|
||||
IsNew: true,
|
||||
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{
|
||||
Title: "Редактирование товара",
|
||||
Email: email,
|
||||
Product: product,
|
||||
Title: "Редактирование товара",
|
||||
Email: email,
|
||||
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"))
|
||||
|
||||
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{
|
||||
ID: productID,
|
||||
Name: r.FormValue("name"),
|
||||
Description: r.FormValue("description"),
|
||||
Details: r.FormValue("details"),
|
||||
Price: price,
|
||||
Category: r.FormValue("category"),
|
||||
SalePrice: salePrice,
|
||||
Category: categoryName,
|
||||
CategoryID: catID,
|
||||
IsActive: r.FormValue("is_active") == "on",
|
||||
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 {
|
||||
email, _ := h.requireAuth(w, r)
|
||||
h.render(w, "admin_product_form.html", adminProductFormData{
|
||||
Title: "Ошибка",
|
||||
Email: email,
|
||||
Product: product,
|
||||
IsNew: isNew,
|
||||
Error: "Заполните название и цену",
|
||||
Title: "Ошибка",
|
||||
Email: email,
|
||||
Product: product,
|
||||
Categories: h.loadCategories(ctx),
|
||||
IsNew: isNew,
|
||||
Error: "Заполните название и цену",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -9,10 +10,12 @@ import (
|
||||
"shop/internal/auth"
|
||||
"shop/internal/models"
|
||||
"shop/internal/repository"
|
||||
"shop/internal/version"
|
||||
)
|
||||
|
||||
type CustomerHandler struct {
|
||||
products *repository.ProductRepository
|
||||
categories *repository.CategoryRepository
|
||||
users *repository.UserRepository
|
||||
cart *repository.CartRepository
|
||||
orders *repository.OrderRepository
|
||||
@@ -26,6 +29,7 @@ type shopPage struct {
|
||||
Title string
|
||||
User *models.User
|
||||
CartCount int
|
||||
Version string
|
||||
Products interface{}
|
||||
Categories interface{}
|
||||
Error string
|
||||
@@ -50,14 +54,26 @@ type checkoutPage struct {
|
||||
|
||||
type accountPage struct {
|
||||
shopPage
|
||||
Orders []models.Order
|
||||
Name string
|
||||
Phone string
|
||||
Orders []models.Order
|
||||
Name string
|
||||
Phone string
|
||||
Address string
|
||||
}
|
||||
|
||||
type productPage struct {
|
||||
shopPage
|
||||
Product models.Product
|
||||
}
|
||||
|
||||
type categoryPage struct {
|
||||
shopPage
|
||||
Category models.Category
|
||||
Products []models.Product
|
||||
}
|
||||
|
||||
func NewCustomerHandler(
|
||||
products *repository.ProductRepository,
|
||||
categories *repository.CategoryRepository,
|
||||
users *repository.UserRepository,
|
||||
cart *repository.CartRepository,
|
||||
orders *repository.OrderRepository,
|
||||
@@ -66,20 +82,21 @@ func NewCustomerHandler(
|
||||
templates *template.Template,
|
||||
) *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,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) render(w http.ResponseWriter, name string, data any) {
|
||||
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 {
|
||||
log.Printf("render %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
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 u, err := h.users.GetByID(r.Context(), uid); err == nil {
|
||||
p.User = &u
|
||||
@@ -117,6 +134,40 @@ func (h *CustomerHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
if _, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"shop/internal/models"
|
||||
"shop/internal/orderstatus"
|
||||
)
|
||||
|
||||
//go:embed templates/*
|
||||
@@ -21,9 +24,27 @@ func stripHTML(s string) string {
|
||||
return tagRe.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
func effectivePrice(p models.Product) float64 { return p.EffectivePrice() }
|
||||
|
||||
func ParseTemplates() (*template.Template, error) {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -250,7 +250,210 @@
|
||||
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) {
|
||||
.product-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.cart-layout,
|
||||
.checkout-layout,
|
||||
.account-layout {
|
||||
|
||||
@@ -337,8 +337,33 @@ body {
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.product-card:hover .product-card__image img {
|
||||
transform: scale(1.05);
|
||||
.product-card {
|
||||
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 {
|
||||
|
||||
@@ -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">
|
||||
<a href="/admin/" 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>
|
||||
</nav>
|
||||
<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>
|
||||
</label>
|
||||
<label class="admin-form__label">
|
||||
Категория
|
||||
<input type="text" name="category" class="admin-form__input" value="{{.Product.Category}}" list="categories">
|
||||
<datalist id="categories">
|
||||
<option value="Электроника">
|
||||
<option value="Одежда">
|
||||
<option value="Дом и сад">
|
||||
<option value="Аксессуары">
|
||||
<option value="Разное">
|
||||
</datalist>
|
||||
Цена со скидкой (₽)
|
||||
<input type="number" name="sale_price" class="admin-form__input" value="{{if .Product.HasSale}}{{printf "%.0f" .Product.SalePrice}}{{end}}" min="0" step="1" placeholder="Без скидки">
|
||||
</label>
|
||||
</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">
|
||||
<input type="checkbox" name="is_active" {{if .Product.IsActive}}checked{{end}}>
|
||||
Показывать в каталоге
|
||||
|
||||
@@ -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}}
|
||||
@@ -43,7 +43,7 @@
|
||||
<h2 class="section__title">Популярные категории</h2>
|
||||
<div class="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__count">{{.Count}} товаров</span>
|
||||
</a>
|
||||
@@ -60,24 +60,7 @@
|
||||
</div>
|
||||
<div class="products">
|
||||
{{range .Products}}
|
||||
<article class="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>
|
||||
{{template "product_card" .}}
|
||||
{{else}}
|
||||
<p class="empty">Товары скоро появятся в каталоге.</p>
|
||||
{{end}}
|
||||
@@ -118,7 +101,7 @@
|
||||
<div class="footer__brand">
|
||||
<span class="logo__icon">◆</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 class="footer__links">
|
||||
<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}}
|
||||
@@ -6,9 +6,9 @@
|
||||
<span class="logo__text">Shop</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="/#catalog" class="nav__link">Каталог</a>
|
||||
<a href="/#catalog" class="nav__link">Каталог</a>
|
||||
<a href="/#categories" class="nav__link">Категории</a>
|
||||
<a href="/#delivery" class="nav__link">Доставка</a>
|
||||
</nav>
|
||||
<div class="header__actions">
|
||||
{{if .User}}
|
||||
|
||||
@@ -19,8 +19,15 @@
|
||||
<div class="cart-item">
|
||||
<img src="{{.Product.ImageURL}}" alt="{{.Product.Name}}" class="cart-item__img">
|
||||
<div class="cart-item__info">
|
||||
<h3>{{.Product.Name}}</h3>
|
||||
<p class="cart-item__price">{{printf "%.0f" .Product.Price}} ₽</p>
|
||||
<h3><a href="/product/{{.ProductID}}">{{.Product.Name}}</a></h3>
|
||||
<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>
|
||||
<form method="POST" action="/cart/update" class="cart-item__qty">
|
||||
<input type="hidden" name="product_id" value="{{.ProductID}}">
|
||||
@@ -164,10 +171,24 @@
|
||||
{{range .Orders}}
|
||||
<div class="order-card">
|
||||
<div class="order-card__head">
|
||||
<strong>Заказ #{{.ID}}</strong>
|
||||
<span>{{.CreatedAt.Format "02.01.2006 15:04"}}</span>
|
||||
<span class="admin-badge admin-badge--ok">{{.Status}}</span>
|
||||
<div>
|
||||
<strong>Заказ #{{.ID}}</strong>
|
||||
<span class="order-card__date">{{.CreatedAt.Format "02.01.2006 15:04"}}</span>
|
||||
</div>
|
||||
<span class="order-status {{orderClass .Status}}">{{orderLabel .Status}}</span>
|
||||
</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">
|
||||
{{range .Items}}
|
||||
<li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li>
|
||||
|
||||
+38
-14
@@ -3,17 +3,38 @@ package models
|
||||
import "time"
|
||||
|
||||
type Product struct {
|
||||
ID int
|
||||
Name string
|
||||
Description string
|
||||
Details string
|
||||
Price float64
|
||||
ImageURL string
|
||||
Category string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Images []ProductImage
|
||||
ID int
|
||||
Name string
|
||||
Description string
|
||||
Details string
|
||||
Price float64
|
||||
SalePrice float64
|
||||
ImageURL string
|
||||
Category string
|
||||
CategoryID *int
|
||||
CategorySlug string
|
||||
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 {
|
||||
@@ -26,9 +47,12 @@ type ProductImage struct {
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
Name string
|
||||
Slug string
|
||||
Count int
|
||||
ID int
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
SortOrder int
|
||||
Count int
|
||||
}
|
||||
|
||||
type SiteStats struct {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"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) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
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
|
||||
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
|
||||
ORDER BY ci.id
|
||||
`, cartID)
|
||||
@@ -153,12 +157,18 @@ func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartIt
|
||||
for rows.Next() {
|
||||
var ci models.CartItem
|
||||
var p models.Product
|
||||
var catID sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&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 {
|
||||
return nil, err
|
||||
}
|
||||
if catID.Valid {
|
||||
id := int(catID.Int64)
|
||||
p.CategoryID = &id
|
||||
}
|
||||
ci.Product = p
|
||||
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) {
|
||||
var total float64
|
||||
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
|
||||
WHERE ci.cart_id = $1
|
||||
`, cartID).Scan(&total)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO order_items (order_id, product_id, product_name, price, quantity)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -19,25 +19,32 @@ func NewProductRepository(pool *pgxpool.Pool) *ProductRepository {
|
||||
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) {
|
||||
var p models.Product
|
||||
var catID sql.NullInt64
|
||||
err := row.Scan(
|
||||
&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,
|
||||
)
|
||||
if catID.Valid {
|
||||
id := int(catID.Int64)
|
||||
p.CategoryID = &id
|
||||
}
|
||||
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) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+productColumns+`
|
||||
FROM products
|
||||
WHERE is_active = true
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
rows, err := r.pool.Query(ctx, productSelect+`
|
||||
WHERE p.is_active = true
|
||||
ORDER BY p.created_at DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
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) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+productColumns+`
|
||||
FROM products
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
rows, err := r.pool.Query(ctx, productSelect+` ORDER BY p.created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
return r.pool.QueryRow(ctx, `
|
||||
INSERT INTO products (name, description, details, price, image_url, category, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO products (name, description, details, price, sale_price, image_url, category, category_id, is_active)
|
||||
VALUES ($1, $2, $3, $4, NULLIF($5, 0), $6, $7, $8, $9)
|
||||
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)
|
||||
}
|
||||
|
||||
func (r *ProductRepository) Update(ctx context.Context, p *models.Product) error {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE products
|
||||
SET name = $1, description = $2, details = $3, price = $4,
|
||||
image_url = $5, category = $6, is_active = $7, updated_at = NOW()
|
||||
WHERE id = $8
|
||||
`, p.Name, p.Description, p.Details, p.Price, p.ImageURL, p.Category, p.IsActive, p.ID)
|
||||
UPDATE products SET name = $1, description = $2, details = $3, price = $4,
|
||||
sale_price = NULLIF($5, 0), image_url = $6, category = $7, category_id = $8,
|
||||
is_active = $9, updated_at = NOW()
|
||||
WHERE id = $10
|
||||
`, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive, p.ID)
|
||||
if err != nil {
|
||||
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) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT category, COUNT(*) AS cnt
|
||||
FROM products
|
||||
GROUP BY category
|
||||
ORDER BY cnt DESC
|
||||
SELECT c.id, c.name, c.slug, c.description, c.sort_order,
|
||||
COUNT(p.id) FILTER (WHERE p.is_active = true)
|
||||
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
|
||||
@@ -119,10 +145,9 @@ func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category,
|
||||
var categories []models.Category
|
||||
for rows.Next() {
|
||||
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
|
||||
}
|
||||
c.Slug = c.Name
|
||||
categories = append(categories, c)
|
||||
}
|
||||
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) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, product_id, file_path, is_primary, sort_order, created_at
|
||||
FROM product_images
|
||||
WHERE product_id = $1
|
||||
FROM product_images WHERE product_id = $1
|
||||
ORDER BY is_primary DESC, sort_order ASC, id ASC
|
||||
`, productID)
|
||||
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 {
|
||||
return r.pool.QueryRow(ctx, `
|
||||
INSERT INTO product_images (product_id, file_path, is_primary, sort_order)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at
|
||||
`, img.ProductID, img.FilePath, img.IsPrimary, img.SortOrder,
|
||||
).Scan(&img.ID, &img.CreatedAt)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id, created_at
|
||||
`, 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) {
|
||||
var path string
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
DELETE FROM product_images
|
||||
WHERE id = $1 AND product_id = $2
|
||||
RETURNING file_path
|
||||
DELETE FROM product_images WHERE id = $1 AND product_id = $2 RETURNING file_path
|
||||
`, imageID, productID).Scan(&path)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", fmt.Errorf("image not found")
|
||||
return "", errors.New("image not found")
|
||||
}
|
||||
return path, err
|
||||
}
|
||||
@@ -177,12 +197,17 @@ func (r *ProductRepository) collectProducts(rows pgx.Rows) ([]models.Product, er
|
||||
var products []models.Product
|
||||
for rows.Next() {
|
||||
var p models.Product
|
||||
var catID sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&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 {
|
||||
return nil, err
|
||||
}
|
||||
if catID.Valid {
|
||||
id := int(catID.Int64)
|
||||
p.CategoryID = &id
|
||||
}
|
||||
products = append(products, p)
|
||||
}
|
||||
return products, rows.Err()
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package version
|
||||
|
||||
// Version задаётся при сборке: -ldflags "-X shop/internal/version.Version=..."
|
||||
var Version = "dev"
|
||||
@@ -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
@@ -25,8 +25,12 @@ if [[ ! -f .env ]]; then
|
||||
echo "Проверьте ADMIN_EMAIL и DOMAIN перед продакшеном."
|
||||
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 up --build -d
|
||||
docker compose build app
|
||||
docker compose up -d --force-recreate
|
||||
|
||||
echo ""
|
||||
echo "Сервисы запущены."
|
||||
|
||||
@@ -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
@@ -4,8 +4,20 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
git pull origin main
|
||||
docker compose up --build -d
|
||||
git fetch origin main
|
||||
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
|
||||
|
||||
sleep 2
|
||||
echo "Версия на сервере:"
|
||||
curl -sf http://127.0.0.1/health || curl -sf http://localhost/health || true
|
||||
echo ""
|
||||
echo "Обновление завершено."
|
||||
|
||||
Reference in New Issue
Block a user