4 Commits
36 changed files with 2653 additions and 390 deletions
+5 -1
View File
@@ -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
+69 -113
View File
@@ -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
+40 -14
View File
@@ -16,6 +16,7 @@ import (
"shop/internal/middleware"
"shop/internal/repository"
"shop/internal/upload"
"shop/internal/version"
)
func main() {
@@ -33,8 +34,12 @@ func main() {
}
productRepo := repository.NewProductRepository(pool)
categoryRepo := repository.NewCategoryRepository(pool)
adminRepo := repository.NewAdminRepository(pool)
statsRepo := repository.NewStatsRepository(pool)
userRepo := repository.NewUserRepository(pool)
cartRepo := repository.NewCartRepository(pool)
orderRepo := repository.NewOrderRepository(pool)
storage, err := upload.NewStorage(cfg.UploadDir)
if err != nil {
@@ -46,35 +51,58 @@ func main() {
log.Fatalf("admin seed: %v", err)
}
log.Printf("admin ready: %s", cfg.AdminEmail)
} else {
log.Println("warning: set ADMIN_EMAIL and ADMIN_PASSWORD in .env to create admin")
}
home, err := handlers.NewHomeHandler(productRepo, statsRepo)
tmpl, err := handlers.ParseTemplates()
if err != nil {
log.Fatalf("handler: %v", err)
log.Fatalf("templates: %v", err)
}
session := auth.NewSession(cfg.SessionSecret)
admin := handlers.NewAdminHandler(adminRepo, productRepo, statsRepo, storage, session, home.Templates())
adminSession := auth.NewSession(cfg.SessionSecret, "shop_admin_session", "/admin")
userSession := auth.NewSession(cfg.SessionSecret, "shop_user_session", "/")
cartSession := auth.NewSession(cfg.SessionSecret, "shop_cart_key", "/")
customer := handlers.NewCustomerHandler(
productRepo, categoryRepo, userRepo, cartRepo, orderRepo, statsRepo,
userSession, cartSession, tmpl,
)
admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, statsRepo, storage, adminSession, tmpl)
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", home.Static()))
mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler()))
mux.Handle("GET /uploads/", storage.FileServer())
mux.HandleFunc("GET /health", home.Health)
mux.HandleFunc("GET /{$}", home.Home)
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)
mux.HandleFunc("POST /login", customer.Login)
mux.HandleFunc("POST /logout", customer.Logout)
mux.HandleFunc("GET /cart", customer.CartPage)
mux.HandleFunc("POST /cart/add", customer.CartAdd)
mux.HandleFunc("POST /cart/update", customer.CartUpdate)
mux.HandleFunc("POST /cart/remove", customer.CartRemove)
mux.HandleFunc("GET /checkout", customer.CheckoutPage)
mux.HandleFunc("POST /checkout", customer.Checkout)
mux.HandleFunc("GET /account", customer.Account)
mux.HandleFunc("POST /account", customer.Account)
mux.HandleFunc("GET /admin/login", admin.LoginPage)
mux.HandleFunc("POST /admin/login", admin.Login)
mux.HandleFunc("POST /admin/logout", admin.Logout)
mux.HandleFunc("GET /admin/{$}", admin.Dashboard)
mux.HandleFunc("GET /admin/products", admin.ProductList)
mux.HandleFunc("GET /admin/products/new", admin.ProductNew)
mux.HandleFunc("POST /admin/products/save", admin.ProductSave)
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{
@@ -86,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)
}
@@ -98,9 +126,7 @@ func main() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
_ = srv.Shutdown(shutdownCtx)
}
func getEnv(key, fallback string) string {
+6
View File
@@ -13,6 +13,8 @@ services:
- ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.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/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
@@ -25,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
@@ -33,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:
+67 -35
View File
@@ -9,27 +9,33 @@ import (
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
const (
cookieName = "shop_session"
sessionTTL = 24 * time.Hour
)
const defaultTTL = 7 * 24 * time.Hour
type Session struct {
secret []byte
secret []byte
cookieName string
path string
ttl time.Duration
}
func NewSession(secret string) *Session {
return &Session{secret: []byte(secret)}
func NewSession(secret, cookieName, path string) *Session {
return &Session{
secret: []byte(secret),
cookieName: cookieName,
path: path,
ttl: defaultTTL,
}
}
func (s *Session) Create(email string) (string, error) {
exp := time.Now().Add(sessionTTL).Unix()
payload := fmt.Sprintf("%s|%d", email, exp)
func (s *Session) Create(value string) (string, error) {
exp := time.Now().Add(s.ttl).Unix()
payload := fmt.Sprintf("%s|%d", value, exp)
sig := s.sign(payload)
token := base64.URLEncoding.EncodeToString([]byte(payload + "|" + sig))
return token, nil
return base64.URLEncoding.EncodeToString([]byte(payload + "|" + sig)), nil
}
func (s *Session) Validate(token string) (string, bool) {
@@ -37,67 +43,93 @@ func (s *Session) Validate(token string) (string, bool) {
if err != nil {
return "", false
}
parts := strings.Split(string(raw), "|")
if len(parts) != 3 {
return "", false
}
email, expStr, sig := parts[0], parts[1], parts[2]
payload := email + "|" + expStr
value, expStr, sig := parts[0], parts[1], parts[2]
payload := value + "|" + expStr
if !hmac.Equal([]byte(sig), []byte(s.sign(payload))) {
return "", false
}
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil || time.Now().Unix() > exp {
return "", false
}
return email, true
return value, true
}
func (s *Session) SetCookie(w http.ResponseWriter, r *http.Request, token string) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Name: s.cookieName,
Value: token,
Path: "/admin",
Path: s.path,
HttpOnly: true,
Secure: s.isSecure(r),
Secure: isSecure(r),
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL.Seconds()),
MaxAge: int(s.ttl.Seconds()),
})
}
func (s *Session) ClearCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Name: s.cookieName,
Value: "",
Path: "/admin",
Path: s.path,
HttpOnly: true,
Secure: s.isSecure(r),
Secure: isSecure(r),
MaxAge: -1,
})
}
func (s *Session) isSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func (s *Session) FromRequest(r *http.Request) (string, bool) {
c, err := r.Cookie(cookieName)
c, err := r.Cookie(s.cookieName)
if err != nil {
return "", false
}
return s.Validate(c.Value)
}
func (s *Session) EnsureKey(w http.ResponseWriter, r *http.Request) string {
if key, ok := s.FromRequest(r); ok && key != "" {
return key
}
key := uuid.New().String()
token, _ := s.Create(key)
s.SetCookie(w, r, token)
return key
}
func (s *Session) sign(payload string) string {
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(payload))
return base64.URLEncoding.EncodeToString(mac.Sum(nil))
}
func isSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func UserIDFromSession(s *Session, r *http.Request) (int, bool) {
val, ok := s.FromRequest(r)
if !ok {
return 0, false
}
if !strings.HasPrefix(val, "user:") {
return 0, false
}
id, err := strconv.Atoi(strings.TrimPrefix(val, "user:"))
return id, err == nil && id > 0
}
func SetUserSession(s *Session, w http.ResponseWriter, r *http.Request, userID int) error {
token, err := s.Create(fmt.Sprintf("user:%d", userID))
if err != nil {
return err
}
s.SetCookie(w, r, token)
return nil
}
+63
View File
@@ -33,6 +33,69 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
visit_date DATE NOT NULL DEFAULT CURRENT_DATE
)`,
`INSERT INTO site_stats (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
phone VARCHAR(50) NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS carts (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
session_key VARCHAR(64) NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_user ON carts (user_id) WHERE user_id IS NOT NULL`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_session ON carts (session_key) WHERE user_id IS NULL`,
`CREATE TABLE IF NOT EXISTS cart_items (
id SERIAL PRIMARY KEY,
cart_id INT NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0),
UNIQUE (cart_id, product_id)
)`,
`CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
guest_name VARCHAR(255) NOT NULL DEFAULT '',
guest_email VARCHAR(255) NOT NULL DEFAULT '',
guest_phone VARCHAR(50) NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
total NUMERIC(10,2) NOT NULL DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT 'new',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS order_items (
id SERIAL PRIMARY KEY,
order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INT NOT NULL,
product_name VARCHAR(255) NOT NULL,
price NUMERIC(10,2) NOT NULL,
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
View File
@@ -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,
}
}
+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{
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
}
+429
View File
@@ -0,0 +1,429 @@
package handlers
import (
"fmt"
"html/template"
"log"
"net/http"
"strconv"
"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
stats *repository.StatsRepository
userSession *auth.Session
cartSession *auth.Session
templates *template.Template
}
type shopPage struct {
Title string
User *models.User
CartCount int
Version string
Products interface{}
Categories interface{}
Error string
Success string
}
type cartPage struct {
shopPage
Items []models.CartItem
Total float64
}
type checkoutPage struct {
shopPage
Items []models.CartItem
Total float64
Name string
Email string
Phone string
Address string
}
type accountPage struct {
shopPage
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,
stats *repository.StatsRepository,
userSession, cartSession *auth.Session,
templates *template.Template,
) *CustomerHandler {
return &CustomerHandler{
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, 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
}
}
cartID, _ := h.resolveCart(r, w)
if cartID > 0 {
if n, err := h.cart.ItemCount(r.Context(), cartID); err == nil {
p.CartCount = n
}
}
return p
}
func (h *CustomerHandler) resolveCart(r *http.Request, w http.ResponseWriter) (int, error) {
ctx := r.Context()
sessionKey := h.cartSession.EnsureKey(w, r)
var userID *int
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
userID = &uid
}
return h.cart.GetOrCreate(ctx, sessionKey, userID)
}
func (h *CustomerHandler) Home(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = h.stats.IncrementHomeVisit(ctx)
products, _ := h.products.Featured(ctx, 8)
categories, _ := h.products.Categories(ctx)
p := h.basePage(r, w, "Shop — Интернет-магазин")
p.Products = products
p.Categories = categories
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)
return
}
h.render(w, "register.html", h.basePage(r, w, "Регистрация"))
}
func (h *CustomerHandler) Register(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
ctx := r.Context()
email := r.FormValue("email")
password := r.FormValue("password")
name := r.FormValue("name")
p := h.basePage(r, w, "Регистрация")
if email == "" || password == "" || name == "" {
p.Error = "Заполните все поля"
h.render(w, "register.html", p)
return
}
if len(password) < 6 {
p.Error = "Пароль минимум 6 символов"
h.render(w, "register.html", p)
return
}
exists, _ := h.users.EmailExists(ctx, email)
if exists {
p.Error = "Email уже зарегистрирован"
h.render(w, "register.html", p)
return
}
u, err := h.users.Create(ctx, email, password, name)
if err != nil {
log.Printf("register: %v", err)
p.Error = "Ошибка регистрации"
h.render(w, "register.html", p)
return
}
sessionKey := h.cartSession.EnsureKey(w, r)
_ = h.cart.MergeToUser(ctx, sessionKey, u.ID)
_ = auth.SetUserSession(h.userSession, w, r, u.ID)
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (h *CustomerHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.UserIDFromSession(h.userSession, r); ok {
http.Redirect(w, r, "/account", http.StatusSeeOther)
return
}
h.render(w, "login.html", h.basePage(r, w, "Вход"))
}
func (h *CustomerHandler) Login(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
ctx := r.Context()
u, err := h.users.Authenticate(ctx, r.FormValue("email"), r.FormValue("password"))
p := h.basePage(r, w, "Вход")
if err != nil {
p.Error = "Неверный email или пароль"
h.render(w, "login.html", p)
return
}
sessionKey := h.cartSession.EnsureKey(w, r)
_ = h.cart.MergeToUser(ctx, sessionKey, u.ID)
_ = auth.SetUserSession(h.userSession, w, r, u.ID)
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (h *CustomerHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.userSession.ClearCookie(w, r)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *CustomerHandler) CartAdd(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
productID, _ := strconv.Atoi(r.FormValue("product_id"))
qty, _ := strconv.Atoi(r.FormValue("quantity"))
if qty < 1 {
qty = 1
}
if productID <= 0 {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
cartID, err := h.resolveCart(r, w)
if err == nil {
_ = h.cart.AddItem(r.Context(), cartID, productID, qty)
}
http.Redirect(w, r, "/cart", http.StatusSeeOther)
}
func (h *CustomerHandler) CartPage(w http.ResponseWriter, r *http.Request) {
cartID, err := h.resolveCart(r, w)
p := cartPage{shopPage: h.basePage(r, w, "Корзина")}
if err != nil || cartID == 0 {
h.render(w, "cart.html", p)
return
}
ctx := r.Context()
p.Items, _ = h.cart.Items(ctx, cartID)
p.Total, _ = h.cart.Total(ctx, cartID)
h.render(w, "cart.html", p)
}
func (h *CustomerHandler) CartUpdate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
productID, _ := strconv.Atoi(r.FormValue("product_id"))
qty, _ := strconv.Atoi(r.FormValue("quantity"))
cartID, _ := h.resolveCart(r, w)
if cartID > 0 {
_ = h.cart.UpdateItem(r.Context(), cartID, productID, qty)
}
http.Redirect(w, r, "/cart", http.StatusSeeOther)
}
func (h *CustomerHandler) CartRemove(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
productID, _ := strconv.Atoi(r.FormValue("product_id"))
cartID, _ := h.resolveCart(r, w)
if cartID > 0 {
_ = h.cart.RemoveItem(r.Context(), cartID, productID)
}
http.Redirect(w, r, "/cart", http.StatusSeeOther)
}
func (h *CustomerHandler) CheckoutPage(w http.ResponseWriter, r *http.Request) {
cartID, _ := h.resolveCart(r, w)
ctx := r.Context()
items, _ := h.cart.Items(ctx, cartID)
if len(items) == 0 {
http.Redirect(w, r, "/cart", http.StatusSeeOther)
return
}
total, _ := h.cart.Total(ctx, cartID)
p := checkoutPage{
shopPage: h.basePage(r, w, "Оформление заказа"),
Items: items,
Total: total,
}
if p.User != nil {
p.Name = p.User.Name
p.Email = p.User.Email
p.Phone = p.User.Phone
p.Address = p.User.Address
}
h.render(w, "checkout.html", p)
}
func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
ctx := r.Context()
cartID, _ := h.resolveCart(r, w)
items, _ := h.cart.Items(ctx, cartID)
if len(items) == 0 {
http.Redirect(w, r, "/cart", http.StatusSeeOther)
return
}
total, _ := h.cart.Total(ctx, cartID)
name := r.FormValue("name")
email := r.FormValue("email")
phone := r.FormValue("phone")
address := r.FormValue("address")
p := checkoutPage{
shopPage: h.basePage(r, w, "Оформление заказа"),
Items: items, Total: total,
Name: name, Email: email, Phone: phone, Address: address,
}
if name == "" || email == "" || phone == "" || address == "" {
p.Error = "Заполните все поля доставки"
h.render(w, "checkout.html", p)
return
}
order := models.Order{
GuestName: name, GuestEmail: email, GuestPhone: phone,
Address: address, Total: total,
}
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
order.UserID = &uid
_ = h.users.UpdateProfile(ctx, &models.User{ID: uid, Name: name, Phone: phone, Address: address})
}
if err := h.orders.Create(ctx, &order, items); err != nil {
log.Printf("checkout: %v", err)
p.Error = "Ошибка оформления заказа"
h.render(w, "checkout.html", p)
return
}
_ = h.cart.Clear(ctx, cartID)
success := h.basePage(r, w, "Заказ оформлен")
success.Success = "Заказ #" + strconv.Itoa(order.ID) + " успешно создан!"
h.render(w, "order_success.html", success)
}
func (h *CustomerHandler) Account(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFromSession(h.userSession, r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := r.Context()
u, err := h.users.GetByID(ctx, uid)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
_ = r.ParseForm()
u.Name = r.FormValue("name")
u.Phone = r.FormValue("phone")
u.Address = r.FormValue("address")
_ = h.users.UpdateProfile(ctx, &u)
}
orders, _ := h.orders.ByUser(ctx, uid)
p := accountPage{
shopPage: h.basePage(r, w, "Личный кабинет"),
Orders: orders,
Name: u.Name,
Phone: u.Phone,
Address: u.Address,
}
p.User = &u
if r.Method == http.MethodPost {
p.Success = "Профиль сохранён"
}
h.render(w, "account.html", p)
}
func (h *CustomerHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprintf(w, `{"status":"ok","version":%q}`, version.Version)
}
+26 -70
View File
@@ -8,7 +8,8 @@ import (
"net/http"
"regexp"
"shop/internal/repository"
"shop/internal/models"
"shop/internal/orderstatus"
)
//go:embed templates/*
@@ -17,86 +18,41 @@ var templateFS embed.FS
//go:embed static/*
var staticFS embed.FS
type HomeHandler struct {
repo *repository.ProductRepository
stats *repository.StatsRepository
templates *template.Template
}
type homePageData struct {
Title string
Products interface{}
Categories interface{}
}
func NewHomeHandler(repo *repository.ProductRepository, stats *repository.StatsRepository) (*HomeHandler, error) {
funcMap := template.FuncMap{
"plain": stripHTML,
}
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
}
return &HomeHandler{repo: repo, stats: stats, templates: tmpl}, nil
}
var tagRe = regexp.MustCompile(`<[^>]*>`)
func stripHTML(s string) string {
return tagRe.ReplaceAllString(s, "")
}
func (h *HomeHandler) Templates() *template.Template {
return h.templates
func effectivePrice(p models.Product) float64 { return p.EffectivePrice() }
func ParseTemplates() (*template.Template, error) {
funcMap := template.FuncMap{
"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")
}
func (h *HomeHandler) Static() http.Handler {
func StaticHandler() http.Handler {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
log.Fatal(err)
}
return http.FileServer(http.FS(sub))
}
func (h *HomeHandler) Home(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
ctx := r.Context()
if err := h.stats.IncrementHomeVisit(ctx); err != nil {
log.Printf("visit counter: %v", err)
}
products, err := h.repo.Featured(ctx, 8)
if err != nil {
log.Printf("featured products: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
categories, err := h.repo.Categories(ctx)
if err != nil {
log.Printf("categories: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := homePageData{
Title: "Shop — Интернет-магазин",
Products: products,
Categories: categories,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, "home.html", data); err != nil {
log.Printf("render: %v", err)
}
}
func (h *HomeHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}
+466
View File
@@ -0,0 +1,466 @@
.shop-page {
padding: 40px 24px 80px;
min-height: 60vh;
}
.shop-card {
background: #fff;
border-radius: 16px;
padding: 32px;
border: 1px solid #e5e7eb;
max-width: 480px;
margin: 0 auto;
}
.shop-card h1 {
font-size: 1.75rem;
margin-bottom: 24px;
}
.shop-card--empty,
.shop-card--success {
text-align: center;
}
.shop-card--success h1 {
color: #059669;
}
.shop-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.shop-form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.875rem;
font-weight: 500;
}
.shop-form input,
.shop-form textarea {
padding: 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 10px;
font-family: inherit;
font-size: 1rem;
}
.shop-form input:focus,
.shop-form textarea:focus {
outline: none;
border-color: #4f46e5;
}
.shop-alert {
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.shop-alert--error {
background: #fef2f2;
color: #b91c1c;
}
.shop-alert--ok {
background: #d1fae5;
color: #065f46;
}
.shop-hint {
font-size: 0.875rem;
color: #6b7280;
margin-top: 16px;
}
.shop-hint a {
color: #4f46e5;
}
.shop-actions {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 24px;
flex-wrap: wrap;
}
.cart-layout,
.checkout-layout,
.account-layout {
display: grid;
gap: 24px;
margin-top: 24px;
}
.cart-layout {
grid-template-columns: 1fr 300px;
}
.checkout-layout {
grid-template-columns: 1fr 320px;
}
.account-layout {
grid-template-columns: 1fr 1fr;
max-width: 1000px;
}
.account-layout .shop-card {
max-width: none;
margin: 0;
}
.account-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.cart-items {
display: flex;
flex-direction: column;
gap: 16px;
}
.cart-item {
display: flex;
align-items: center;
gap: 16px;
background: #fff;
padding: 16px;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.cart-item__img {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
}
.cart-item__info {
flex: 1;
}
.cart-item__info h3 {
font-size: 1rem;
margin-bottom: 4px;
}
.cart-item__price {
color: #4f46e5;
font-weight: 600;
}
.cart-item__qty {
display: flex;
gap: 8px;
align-items: center;
}
.cart-item__qty input {
width: 60px;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.cart-summary {
background: #fff;
padding: 24px;
border-radius: 16px;
border: 1px solid #e5e7eb;
height: fit-content;
position: sticky;
top: 88px;
}
.cart-summary h2 {
font-size: 1.1rem;
margin-bottom: 16px;
}
.cart-summary__total {
font-size: 1.5rem;
font-weight: 700;
margin: 16px 0;
color: #1a1a2e;
}
.checkout-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f3f4f6;
font-size: 0.9rem;
}
.order-card {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
.order-card__head {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
font-size: 0.9rem;
}
.order-card__items {
list-style: none;
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 8px;
}
.order-card__total {
font-weight: 600;
}
.order-card .admin-badge,
.order-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 100px;
font-size: 0.75rem;
font-weight: 600;
background: #d1fae5;
color: #065f46;
}
.btn--danger {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
}
.product-card__footer form {
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 {
grid-template-columns: 1fr;
}
.cart-item {
flex-wrap: wrap;
}
}
+27 -2
View File
@@ -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}}>
Показывать в каталоге
+57
View File
@@ -0,0 +1,57 @@
{{define "register.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/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<div class="shop-card">
<h1>Регистрация</h1>
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
<form method="POST" action="/register" class="shop-form">
<label>Имя<input type="text" name="name" required></label>
<label>Email<input type="email" name="email" required></label>
<label>Пароль (мин. 6 символов)<input type="password" name="password" required minlength="6"></label>
<button type="submit" class="btn btn--primary btn--lg">Зарегистрироваться</button>
</form>
<p class="shop-hint">Уже есть аккаунт? <a href="/login">Войти</a></p>
<p class="shop-hint">Можно покупать и <a href="/cart">без регистрации</a></p>
</div>
</main>
</body>
</html>
{{end}}
{{define "login.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/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<div class="shop-card">
<h1>Вход</h1>
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
<form method="POST" action="/login" class="shop-form">
<label>Email<input type="email" name="email" required></label>
<label>Пароль<input type="password" name="password" required></label>
<button type="submit" class="btn btn--primary btn--lg">Войти</button>
</form>
<p class="shop-hint">Нет аккаунта? <a href="/register">Регистрация</a></p>
</div>
</main>
</body>
</html>
{{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}}
+20 -55
View File
@@ -11,38 +11,17 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="header">
<div class="container header__inner">
<a href="/" class="logo">
<span class="logo__icon"></span>
<span class="logo__text">Shop</span>
</a>
<nav class="nav">
<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">
<button class="btn btn--ghost" aria-label="Поиск">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</button>
<button class="btn btn--primary">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>
Корзина
</button>
</div>
</div>
</header>
{{template "shop_header" .}}
<section class="hero">
<div class="container hero__inner">
<div class="hero__content">
<span class="badge">Новая коллекция 2026</span>
<h1 class="hero__title">Всё лучшее —<br>в одном магазине</h1>
<p class="hero__text">Электроника, одежда, дом и сад. Быстрая доставка по всей стране, гарантия качества и удобная оплата.</p>
<p class="hero__text">Электроника, одежда, дом и сад. Покупайте с регистрацией или без — как вам удобно.</p>
<div class="hero__cta">
<a href="#catalog" class="btn btn--primary btn--lg">Смотреть каталог</a>
<a href="#delivery" class="btn btn--outline btn--lg">Условия доставки</a>
<a href="/register" class="btn btn--outline btn--lg">Создать аккаунт</a>
</div>
</div>
<div class="hero__visual">
@@ -51,8 +30,8 @@
<span class="hero__card-value">от 3 000 ₽</span>
</div>
<div class="hero__card hero__card--2">
<span class="hero__card-label">Товаров в каталоге</span>
<span class="hero__card-value">10 000+</span>
<span class="hero__card-label">Покупка без регистрации</span>
<span class="hero__card-value">Доступна</span>
</div>
</div>
</div>
@@ -64,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>
@@ -78,24 +57,10 @@
<div class="container">
<div class="section__header">
<h2 class="section__title">Популярные товары</h2>
<a href="#" class="link">Весь каталог →</a>
</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>
<button class="btn btn--primary btn--sm">В корзину</button>
</div>
</div>
</article>
{{template "product_card" .}}
{{else}}
<p class="empty">Товары скоро появятся в каталоге.</p>
{{end}}
@@ -110,22 +75,22 @@
<div class="feature">
<div class="feature__icon">🚚</div>
<h3 class="feature__title">Быстрая доставка</h3>
<p class="feature__text">Доставим заказ за 1–3 дня в любой город России</p>
<p class="feature__text">Доставим заказ за 1–3 дня</p>
</div>
<div class="feature">
<div class="feature__icon">👤</div>
<h3 class="feature__title">Без регистрации</h3>
<p class="feature__text">Оформите заказ как гость — достаточно email и телефона</p>
</div>
<div class="feature">
<div class="feature__icon">🔒</div>
<h3 class="feature__title">Безопасная оплата</h3>
<p class="feature__text">Карты, СБП и оплата при получении</p>
</div>
<div class="feature">
<div class="feature__icon">↩️</div>
<h3 class="feature__title">Лёгкий возврат</h3>
<p class="feature__text">14 дней на возврат без лишних вопросов</p>
<h3 class="feature__title">Личный кабинет</h3>
<p class="feature__text">История заказов и сохранённые данные для постоянных клиентов</p>
</div>
<div class="feature">
<div class="feature__icon">💬</div>
<h3 class="feature__title">Поддержка 24/7</h3>
<p class="feature__text">Ответим на любой вопрос в чате или по телефону</p>
<p class="feature__text">Ответим на любой вопрос</p>
</div>
</div>
</div>
@@ -136,12 +101,12 @@
<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="#">О компании</a>
<a href="#">Контакты</a>
<a href="#">Политика конфиденциальности</a>
<a href="/account">Личный кабинет</a>
<a href="/cart">Корзина</a>
<a href="/login">Вход</a>
</div>
</div>
</footer>
@@ -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}}
@@ -0,0 +1,26 @@
{{define "shop_header"}}
<header class="header">
<div class="container header__inner">
<a href="/" class="logo">
<span class="logo__icon"></span>
<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>
</nav>
<div class="header__actions">
{{if .User}}
<a href="/account" class="btn btn--ghost btn--sm">👤 {{.User.Name}}</a>
{{else}}
<a href="/login" class="btn btn--ghost btn--sm">Войти</a>
<a href="/register" class="btn btn--ghost btn--sm">Регистрация</a>
{{end}}
<a href="/cart" class="btn btn--primary btn--sm">
🛒 Корзина{{if .CartCount}} ({{.CartCount}}){{end}}
</a>
</div>
</div>
</header>
{{end}}
+208
View File
@@ -0,0 +1,208 @@
{{define "cart.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/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<h1>Корзина</h1>
{{if .Items}}
<div class="cart-layout">
<div class="cart-items">
{{range .Items}}
<div class="cart-item">
<img src="{{.Product.ImageURL}}" alt="{{.Product.Name}}" class="cart-item__img">
<div class="cart-item__info">
<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}}">
<input type="number" name="quantity" value="{{.Quantity}}" min="1" max="99">
<button type="submit" class="btn btn--ghost btn--sm">OK</button>
</form>
<form method="POST" action="/cart/remove">
<input type="hidden" name="product_id" value="{{.ProductID}}">
<button type="submit" class="btn btn--danger btn--sm">×</button>
</form>
</div>
{{end}}
</div>
<aside class="cart-summary">
<h2>Итого</h2>
<p class="cart-summary__total">{{printf "%.0f" .Total}} ₽</p>
<a href="/checkout" class="btn btn--primary btn--lg">Оформить заказ</a>
<p class="shop-hint">Регистрация не обязательна</p>
</aside>
</div>
{{else}}
<div class="shop-card shop-card--empty">
<p>Корзина пуста</p>
<a href="/" class="btn btn--primary">В каталог</a>
</div>
{{end}}
</main>
</body>
</html>
{{end}}
{{define "checkout.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/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<h1>Оформление заказа</h1>
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
<div class="checkout-layout">
<form method="POST" action="/checkout" class="shop-card shop-form">
<h2>Данные для доставки</h2>
{{if .User}}
<p class="shop-hint">Вы вошли как {{.User.Email}}. Данные подставлены из профиля.</p>
{{else}}
<p class="shop-hint">Покупка без регистрации — укажите контакты для доставки.</p>
{{end}}
<label>Имя<input type="text" name="name" value="{{.Name}}" required></label>
<label>Email<input type="email" name="email" value="{{.Email}}" required></label>
<label>Телефон<input type="tel" name="phone" value="{{.Phone}}" required></label>
<label>Адрес доставки<textarea name="address" rows="3" required>{{.Address}}</textarea></label>
<button type="submit" class="btn btn--primary btn--lg">Подтвердить заказ</button>
</form>
<aside class="cart-summary">
<h2>Ваш заказ</h2>
{{range .Items}}
<div class="checkout-item">
<span>{{.Product.Name}} × {{.Quantity}}</span>
<span>{{printf "%.0f" .Product.Price}} ₽</span>
</div>
{{end}}
<p class="cart-summary__total">Итого: {{printf "%.0f" .Total}} ₽</p>
</aside>
</div>
</main>
</body>
</html>
{{end}}
{{define "order_success.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/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<div class="shop-card shop-card--success">
<h1>✓ Заказ оформлен!</h1>
<p>{{.Success}}</p>
<p class="shop-hint">Мы свяжемся с вами для подтверждения доставки.</p>
<div class="shop-actions">
<a href="/" class="btn btn--primary">На главную</a>
{{if .User}}<a href="/account" class="btn btn--outline">Мои заказы</a>{{end}}
</div>
</div>
</main>
</body>
</html>
{{end}}
{{define "account.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/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<div class="account-header">
<h1>Личный кабинет</h1>
<form method="POST" action="/logout">
<button type="submit" class="btn btn--ghost">Выйти</button>
</form>
</div>
{{if .Success}}<div class="shop-alert shop-alert--ok">{{.Success}}</div>{{end}}
<div class="account-layout">
<section class="shop-card">
<h2>Профиль</h2>
<p class="shop-hint">Email: {{.User.Email}}</p>
<form method="POST" action="/account" class="shop-form">
<label>Имя<input type="text" name="name" value="{{.Name}}" required></label>
<label>Телефон<input type="tel" name="phone" value="{{.Phone}}"></label>
<label>Адрес<textarea name="address" rows="2">{{.Address}}</textarea></label>
<button type="submit" class="btn btn--primary">Сохранить</button>
</form>
</section>
<section class="shop-card">
<h2>Мои заказы</h2>
{{if .Orders}}
{{range .Orders}}
<div class="order-card">
<div class="order-card__head">
<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>
{{end}}
</ul>
<p class="order-card__total">Итого: {{printf "%.0f" .Total}} ₽</p>
</div>
{{end}}
{{else}}
<p class="shop-hint">Заказов пока нет. <a href="/">Перейти в каталог</a></p>
{{end}}
</section>
</div>
</main>
</body>
</html>
{{end}}
+38 -14
View File
@@ -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 {
+50
View File
@@ -0,0 +1,50 @@
package models
import "time"
type User struct {
ID int
Email string
Name string
Phone string
Address string
CreatedAt time.Time
}
type Cart struct {
ID int
UserID *int
SessionKey string
Items []CartItem
UpdatedAt time.Time
}
type CartItem struct {
ID int
CartID int
ProductID int
Quantity int
Product Product
}
type Order struct {
ID int
UserID *int
GuestName string
GuestEmail string
GuestPhone string
Address string
Total float64
Status string
CreatedAt time.Time
Items []OrderItem
}
type OrderItem struct {
ID int
OrderID int
ProductID int
ProductName string
Price float64
Quantity int
}
+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
}
+188
View File
@@ -0,0 +1,188 @@
package repository
import (
"context"
"database/sql"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type CartRepository struct {
pool *pgxpool.Pool
}
func NewCartRepository(pool *pgxpool.Pool) *CartRepository {
return &CartRepository{pool: pool}
}
func (r *CartRepository) GetOrCreate(ctx context.Context, sessionKey string, userID *int) (int, error) {
if userID != nil {
var cartID int
err := r.pool.QueryRow(ctx, `SELECT id FROM carts WHERE user_id = $1`, *userID).Scan(&cartID)
if err == nil {
return cartID, nil
}
if err != pgx.ErrNoRows {
return 0, err
}
err = r.pool.QueryRow(ctx, `
INSERT INTO carts (user_id, session_key) VALUES ($1, $2) RETURNING id
`, *userID, sessionKey).Scan(&cartID)
return cartID, err
}
var cartID int
err := r.pool.QueryRow(ctx, `
SELECT id FROM carts WHERE session_key = $1 AND user_id IS NULL
`, sessionKey).Scan(&cartID)
if err == nil {
return cartID, nil
}
if err != pgx.ErrNoRows {
return 0, err
}
err = r.pool.QueryRow(ctx, `
INSERT INTO carts (session_key) VALUES ($1) RETURNING id
`, sessionKey).Scan(&cartID)
return cartID, err
}
func (r *CartRepository) MergeToUser(ctx context.Context, sessionKey string, userID int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var guestCartID int
err = tx.QueryRow(ctx, `
SELECT id FROM carts WHERE session_key = $1 AND user_id IS NULL
`, sessionKey).Scan(&guestCartID)
if err == pgx.ErrNoRows {
return tx.Commit(ctx)
}
if err != nil {
return err
}
var userCartID int
err = tx.QueryRow(ctx, `SELECT id FROM carts WHERE user_id = $1`, userID).Scan(&userCartID)
if err == pgx.ErrNoRows {
_, err = tx.Exec(ctx, `UPDATE carts SET user_id = $1 WHERE id = $2`, userID, guestCartID)
if err != nil {
return err
}
return tx.Commit(ctx)
}
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
INSERT INTO cart_items (cart_id, product_id, quantity)
SELECT $1, product_id, quantity FROM cart_items WHERE cart_id = $2
ON CONFLICT (cart_id, product_id) DO UPDATE
SET quantity = cart_items.quantity + EXCLUDED.quantity
`, userCartID, guestCartID)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `DELETE FROM carts WHERE id = $1`, guestCartID)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func (r *CartRepository) AddItem(ctx context.Context, cartID, productID, qty int) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO cart_items (cart_id, product_id, quantity)
VALUES ($1, $2, $3)
ON CONFLICT (cart_id, product_id) DO UPDATE
SET quantity = cart_items.quantity + EXCLUDED.quantity
`, cartID, productID, qty)
return err
}
func (r *CartRepository) UpdateItem(ctx context.Context, cartID, productID, qty int) error {
if qty <= 0 {
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2`, cartID, productID)
return err
}
_, err := r.pool.Exec(ctx, `
UPDATE cart_items SET quantity = $3 WHERE cart_id = $1 AND product_id = $2
`, cartID, productID, qty)
return err
}
func (r *CartRepository) RemoveItem(ctx context.Context, cartID, productID int) error {
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2`, cartID, productID)
return err
}
func (r *CartRepository) Clear(ctx context.Context, cartID int) error {
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1`, cartID)
return err
}
func (r *CartRepository) ItemCount(ctx context.Context, cartID int) (int, error) {
var count int
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(SUM(quantity), 0) FROM cart_items WHERE cart_id = $1
`, cartID).Scan(&count)
return count, err
}
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, 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)
if err != nil {
return nil, err
}
defer rows.Close()
var items []models.CartItem
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.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)
}
return items, rows.Err()
}
func (r *CartRepository) Total(ctx context.Context, cartID int) (float64, error) {
var total float64
err := r.pool.QueryRow(ctx, `
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)
return total, err
}
+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
}
+93
View File
@@ -0,0 +1,93 @@
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type OrderRepository struct {
pool *pgxpool.Pool
}
func NewOrderRepository(pool *pgxpool.Pool) *OrderRepository {
return &OrderRepository{pool: pool}
}
func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items []models.CartItem) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
err = tx.QueryRow(ctx, `
INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status)
VALUES ($1, $2, $3, $4, $5, $6, 'new')
RETURNING id, created_at
`, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total,
).Scan(&order.ID, &order.CreatedAt)
if err != nil {
return err
}
for _, item := range 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.EffectivePrice(), item.Quantity)
if err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (r *OrderRepository) ByUser(ctx context.Context, userID int) ([]models.Order, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at
FROM orders WHERE user_id = $1 ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var orders []models.Order
for rows.Next() {
var o models.Order
if err := rows.Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt); err != nil {
return nil, err
}
o.Items, err = r.items(ctx, o.ID)
if err != nil {
return nil, err
}
orders = append(orders, o)
}
return orders, rows.Err()
}
func (r *OrderRepository) items(ctx context.Context, orderID int) ([]models.OrderItem, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, order_id, product_id, product_name, price, quantity
FROM order_items WHERE order_id = $1
`, orderID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []models.OrderItem
for rows.Next() {
var i models.OrderItem
if err := rows.Scan(&i.ID, &i.OrderID, &i.ProductID, &i.ProductName, &i.Price, &i.Quantity); err != nil {
return nil, err
}
items = append(items, i)
}
return items, rows.Err()
}
+69 -44
View File
@@ -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()
+75
View File
@@ -0,0 +1,75 @@
package repository
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"shop/internal/models"
)
type UserRepository struct {
pool *pgxpool.Pool
}
func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
return &UserRepository{pool: pool}
}
func (r *UserRepository) Create(ctx context.Context, email, password, name string) (models.User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return models.User{}, err
}
var u models.User
err = r.pool.QueryRow(ctx, `
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, phone, address, created_at
`, email, string(hash), name).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt)
return u, err
}
func (r *UserRepository) Authenticate(ctx context.Context, email, password string) (models.User, error) {
var hash string
var u models.User
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, phone, address, created_at, password_hash
FROM users WHERE email = $1
`, email).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt, &hash)
if errors.Is(err, pgx.ErrNoRows) {
return models.User{}, pgx.ErrNoRows
}
if err != nil {
return models.User{}, err
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
return models.User{}, pgx.ErrNoRows
}
return u, nil
}
func (r *UserRepository) GetByID(ctx context.Context, id int) (models.User, error) {
var u models.User
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, phone, address, created_at FROM users WHERE id = $1
`, id).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt)
return u, err
}
func (r *UserRepository) UpdateProfile(ctx context.Context, u *models.User) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET name = $1, phone = $2, address = $3 WHERE id = $4
`, u.Name, u.Phone, u.Address, u.ID)
return err
}
func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email).Scan(&exists)
return exists, err
}
+4
View File
@@ -0,0 +1,4 @@
package version
// Version задаётся при сборке: -ldflags "-X shop/internal/version.Version=..."
var Version = "dev"
+52
View File
@@ -0,0 +1,52 @@
-- Пользователи, корзина, заказы
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
phone VARCHAR(50) NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS carts (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
session_key VARCHAR(64) NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_user ON carts (user_id) WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_session ON carts (session_key) WHERE user_id IS NULL;
CREATE TABLE IF NOT EXISTS cart_items (
id SERIAL PRIMARY KEY,
cart_id INT NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0),
UNIQUE (cart_id, product_id)
);
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
guest_name VARCHAR(255) NOT NULL DEFAULT '',
guest_email VARCHAR(255) NOT NULL DEFAULT '',
guest_phone VARCHAR(50) NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
total NUMERIC(10, 2) NOT NULL DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT 'new',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS order_items (
id SERIAL PRIMARY KEY,
order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INT NOT NULL,
product_name VARCHAR(255) NOT NULL,
price NUMERIC(10, 2) NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0)
);
CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id);
+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 перед продакшеном."
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 "Сервисы запущены."
+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)"
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 "Обновление завершено."