5 Commits
47 changed files with 3077 additions and 312 deletions
+8
View File
@@ -10,6 +10,14 @@ ADMIN_PASSWORD=change_me
# Секрет для сессий (обязательно смените в продакшене) # Секрет для сессий (обязательно смените в продакшене)
SESSION_SECRET=your_random_secret_key_here SESSION_SECRET=your_random_secret_key_here
# Публичный URL магазина (для callback Heleket)
# PUBLIC_BASE_URL=https://shop.example.com
# Heleket — крипто-платежи (https://doc.heleket.com/ru)
# HELEKET_MERCHANT_ID=your-merchant-uuid
# HELEKET_PAYMENT_API_KEY=your-payment-api-key
# HELEKET_CURRENCY=RUB
# Часовой пояс сервера (Ubuntu) # Часовой пояс сервера (Ubuntu)
TZ=Europe/Moscow TZ=Europe/Moscow
+5 -1
View File
@@ -9,7 +9,11 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /shop ./cmd/shop ARG APP_VERSION=dev
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X shop/internal/version.Version=${APP_VERSION}" \
-o /shop ./cmd/shop
# Runtime stage # Runtime stage
FROM alpine:3.21 FROM alpine:3.21
+73 -113
View File
@@ -1,8 +1,20 @@
# Shop — Интернет-магазин # Shop — Интернет-магазин
Главная страница интернет-магазина на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy** (обратный прокси + SSL). Полнофункциональный интернет-магазин на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy**.
Проект настроен для развёртывания на **Ubuntu 22.04 / 24.04**. **Текущая версия:** `v1.0`
## Возможности
| Модуль | Описание |
|--------|----------|
| Каталог | Главная, категории, карточки товаров со скидками |
| Товар | Страница `/product/{id}` с описанием, фото и скриншотами |
| Категории | `/category/{slug}` — управление в админке |
| Корзина | Добавление, изменение количества, расчёт со скидкой |
| Заказы | Оформление гостем или зарегистрированным пользователем |
| Личный кабинет | Профиль, история заказов со статус-таймлайном |
| Админка | Товары, категории, заказы, скриншоты, редактор Quill, статистика |
## Стек ## Стек
@@ -12,164 +24,112 @@
| База данных | PostgreSQL 17 | | База данных | PostgreSQL 17 |
| Прокси / SSL | Caddy 2 | | Прокси / SSL | Caddy 2 |
| ОС | Ubuntu 22.04 / 24.04 | | ОС | Ubuntu 22.04 / 24.04 |
| Контейнеризация | Docker Compose |
## Быстрый старт на Ubuntu ## Быстрый старт (Ubuntu)
```bash ```bash
# Клонировать репозиторий
git clone https://git.evilfox.cc/test2/shop-go.git /opt/shop git clone https://git.evilfox.cc/test2/shop-go.git /opt/shop
cd /opt/shop cd /opt/shop
sudo bash setup.sh
# 1. Установить Docker (один раз, с sudo)
sudo ./scripts/install-ubuntu.sh
# Перелогиниться или:
newgrp docker
# 2. Запустить магазин
chmod +x scripts/*.sh
./scripts/deploy.sh
``` ```
Откройте в браузере: Или обновление существующего:
- `http://IP_СЕРВЕРА` — магазин
- `http://IP_СЕРВЕРА/admin/login` — админка
Или через Make:
```bash ```bash
make install # sudo, установка Docker cd /opt/shop
make deploy # первый запуск bash scripts/redeploy.sh
make logs # просмотр логов
``` ```
## Продакшен с доменом и SSL Проверка версии:
1. Настройте DNS: A-запись домена → IP сервера Ubuntu
2. Откройте порты 80 и 443 (скрипт `install-ubuntu.sh` настраивает ufw)
3. Включите SSL:
```bash ```bash
./scripts/enable-ssl.sh shop.example.com admin@example.com curl http://localhost/health
# {"status":"ok","version":"v1.0"}
``` ```
Или: ## Маршруты магазина
```bash | Путь | Описание |
make ssl DOMAIN=shop.example.com EMAIL=admin@example.com |------|----------|
``` | `/` | Главная |
| `/product/{id}` | Страница товара |
| `/category/{slug}` | Категория |
| `/cart` | Корзина |
| `/checkout` | Оформление заказа |
| `/register`, `/login` | Регистрация и вход |
| `/account` | Личный кабинет |
| `/admin/` | Админ-панель |
| `/admin/orders` | Управление заказами и статусами |
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 | `pending` | В ожидании |
sudo systemctl daemon-reload | `unpaid` | Не оплачено |
sudo systemctl enable --now shop | `paid` | Оплачено |
``` | `processing` | В обработке |
| `shipped` | Отправлен |
| `delivered` | Доставлен |
| `cancelled` | Отменён |
| `refunded` | Возврат |
В личном кабинете — цветной бейдж и таймлайн прогресса.
В админке → **Заказы** — смена статуса из выпадающего списка.
## Админ ## Админ
Администратор создаётся автоматически при старте из `.env`:
```env ```env
ADMIN_EMAIL=admin@shop.local ADMIN_EMAIL=admin@shop.local
ADMIN_PASSWORD=change_me ADMIN_PASSWORD=change_me
SESSION_SECRET=your_random_secret_key_here SESSION_SECRET=your_random_secret_key_here
``` ```
Скрипт `deploy.sh` генерирует пароли автоматически при первом запуске.
Вход: `/admin/login` Вход: `/admin/login`
При изменении `ADMIN_PASSWORD` пароль обновится в БД при следующем запуске. ## SSL
## Сервисы
| Сервис | Порт | Описание |
|--------|------|----------|
| Caddy | 80, 443 | Обратный прокси, SSL |
| App | 8080 (внутри) | Go-приложение |
| PostgreSQL | 5432 (внутри) | База данных |
## Обновление на сервере
```bash ```bash
./scripts/update.sh ./scripts/enable-ssl.sh shop.example.com admin@example.com
# или
make update
``` ```
## Локальная разработка (Ubuntu) ## Обновление
```bash ```bash
# Только PostgreSQL в Docker bash scripts/redeploy.sh
docker compose up db -d
export DB_HOST=localhost DB_USER=shop DB_PASSWORD=shop_secret DB_NAME=shop
export ADMIN_EMAIL=admin@shop.local ADMIN_PASSWORD=change_me
go mod tidy
go run ./cmd/shop
# http://localhost:8080
``` ```
## Структура проекта Скрипт делает `git reset --hard`, пересборку Docker без кэша и проверку `/health`.
## Структура
``` ```
shop/ shop/
├── cmd/shop/main.go ├── cmd/shop/ # Точка входа
├── internal/ ├── internal/
│ ├── handlers/ # HTTP, шаблоны, статика
│ ├── repository/ # БД
│ ├── orderstatus/ # Статусы заказов
│ └── ...
├── migrations/ ├── migrations/
├── scripts/ ├── scripts/
│ ├── install-ubuntu.sh # установка Docker + ufw │ ├── deploy.sh
│ ├── deploy.sh # первый запуск │ ├── redeploy.sh # Полное обновление
── enable-ssl.sh # SSL для домена ── update.sh
│ └── update.sh # обновление └── docker-compose.yml
├── systemd/shop.service # автозапуск
├── Caddyfile # HTTP (IP / localhost)
├── Caddyfile.prod # HTTPS (домен)
├── docker-compose.yml
├── Makefile
└── Dockerfile
``` ```
## API ## Требования
| Метод | Путь | Описание | - Ubuntu 22.04 / 24.04
|-------|------|----------| - 1 CPU, 1 GB RAM
| GET | `/` | Главная страница | - Порты 22, 80, 443
| GET | `/health` | Health check |
| GET | `/static/*` | CSS и статика |
| GET | `/admin/login` | Страница входа |
| POST | `/admin/login` | Авторизация |
| GET | `/admin/` | Админ-панель |
| POST | `/admin/logout` | Выход |
## Остановка
```bash
docker compose down
# С удалением данных БД
docker compose down -v
```
## Требования к серверу Ubuntu
- 1 CPU, 1 GB RAM (минимум)
- Ubuntu 22.04 или 24.04 LTS
- Открытые порты 22 (SSH), 80, 443
+34 -4
View File
@@ -13,9 +13,11 @@ import (
"shop/internal/config" "shop/internal/config"
"shop/internal/database" "shop/internal/database"
"shop/internal/handlers" "shop/internal/handlers"
"shop/internal/heleket"
"shop/internal/middleware" "shop/internal/middleware"
"shop/internal/repository" "shop/internal/repository"
"shop/internal/upload" "shop/internal/upload"
"shop/internal/version"
) )
func main() { func main() {
@@ -33,12 +35,16 @@ func main() {
} }
productRepo := repository.NewProductRepository(pool) productRepo := repository.NewProductRepository(pool)
categoryRepo := repository.NewCategoryRepository(pool)
adminRepo := repository.NewAdminRepository(pool) adminRepo := repository.NewAdminRepository(pool)
statsRepo := repository.NewStatsRepository(pool) statsRepo := repository.NewStatsRepository(pool)
userRepo := repository.NewUserRepository(pool) userRepo := repository.NewUserRepository(pool)
cartRepo := repository.NewCartRepository(pool) cartRepo := repository.NewCartRepository(pool)
orderRepo := repository.NewOrderRepository(pool) orderRepo := repository.NewOrderRepository(pool)
priceHistoryRepo := repository.NewPriceHistoryRepository(pool)
reservationRepo := repository.NewReservationRepository(pool)
storage, err := upload.NewStorage(cfg.UploadDir) storage, err := upload.NewStorage(cfg.UploadDir)
if err != nil { if err != nil {
log.Fatalf("upload storage: %v", err) log.Fatalf("upload storage: %v", err)
@@ -59,12 +65,21 @@ func main() {
adminSession := auth.NewSession(cfg.SessionSecret, "shop_admin_session", "/admin") adminSession := auth.NewSession(cfg.SessionSecret, "shop_admin_session", "/admin")
userSession := auth.NewSession(cfg.SessionSecret, "shop_user_session", "/") userSession := auth.NewSession(cfg.SessionSecret, "shop_user_session", "/")
cartSession := auth.NewSession(cfg.SessionSecret, "shop_cart_key", "/") cartSession := auth.NewSession(cfg.SessionSecret, "shop_cart_key", "/")
compareSession := auth.NewSession(cfg.SessionSecret, "shop_compare", "/")
heleketClient := heleket.NewClient(cfg.HeleketMerchantID, cfg.HeleketPaymentAPIKey)
if heleketClient.Enabled() {
log.Printf("heleket payments enabled (currency: %s)", cfg.HeleketCurrency)
}
customer := handlers.NewCustomerHandler( customer := handlers.NewCustomerHandler(
productRepo, userRepo, cartRepo, orderRepo, statsRepo, productRepo, categoryRepo, userRepo, cartRepo, orderRepo,
userSession, cartSession, tmpl, priceHistoryRepo, reservationRepo, statsRepo,
userSession, cartSession, compareSession,
heleketClient, cfg.HeleketPaymentAPIKey, cfg.PublicBaseURL, cfg.HeleketCurrency,
tmpl,
) )
admin := handlers.NewAdminHandler(adminRepo, productRepo, statsRepo, storage, adminSession, tmpl) admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, orderRepo, priceHistoryRepo, statsRepo, storage, adminSession, tmpl)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler())) mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler()))
@@ -72,6 +87,13 @@ func main() {
mux.HandleFunc("GET /health", customer.Health) mux.HandleFunc("GET /health", customer.Health)
mux.HandleFunc("GET /{$}", customer.Home) mux.HandleFunc("GET /{$}", customer.Home)
mux.HandleFunc("GET /product/{id}", customer.ProductPage)
mux.HandleFunc("POST /product/{id}/reserve", customer.ProductReserve)
mux.HandleFunc("POST /buy", customer.BuyNow)
mux.HandleFunc("GET /compare", customer.ComparePage)
mux.HandleFunc("POST /compare/add", customer.CompareAdd)
mux.HandleFunc("POST /compare/remove", customer.CompareRemove)
mux.HandleFunc("GET /category/{slug}", customer.CategoryPage)
mux.HandleFunc("GET /register", customer.RegisterPage) mux.HandleFunc("GET /register", customer.RegisterPage)
mux.HandleFunc("POST /register", customer.Register) mux.HandleFunc("POST /register", customer.Register)
mux.HandleFunc("GET /login", customer.LoginPage) mux.HandleFunc("GET /login", customer.LoginPage)
@@ -83,6 +105,9 @@ func main() {
mux.HandleFunc("POST /cart/remove", customer.CartRemove) mux.HandleFunc("POST /cart/remove", customer.CartRemove)
mux.HandleFunc("GET /checkout", customer.CheckoutPage) mux.HandleFunc("GET /checkout", customer.CheckoutPage)
mux.HandleFunc("POST /checkout", customer.Checkout) mux.HandleFunc("POST /checkout", customer.Checkout)
mux.HandleFunc("GET /order/{id}/success", customer.OrderSuccess)
mux.HandleFunc("GET /order/{id}/pay", customer.OrderPay)
mux.HandleFunc("POST /webhooks/heleket", customer.HeleketWebhook)
mux.HandleFunc("GET /account", customer.Account) mux.HandleFunc("GET /account", customer.Account)
mux.HandleFunc("POST /account", customer.Account) mux.HandleFunc("POST /account", customer.Account)
@@ -96,6 +121,11 @@ func main() {
mux.HandleFunc("GET /admin/products/{id}/edit", admin.ProductEdit) mux.HandleFunc("GET /admin/products/{id}/edit", admin.ProductEdit)
mux.HandleFunc("POST /admin/products/{id}/delete", admin.ProductDelete) mux.HandleFunc("POST /admin/products/{id}/delete", admin.ProductDelete)
mux.HandleFunc("POST /admin/products/{id}/images/{imageId}/delete", admin.ImageDelete) mux.HandleFunc("POST /admin/products/{id}/images/{imageId}/delete", admin.ImageDelete)
mux.HandleFunc("GET /admin/categories", admin.CategoryList)
mux.HandleFunc("POST /admin/categories", admin.CategoryCreate)
mux.HandleFunc("POST /admin/categories/{id}/delete", admin.CategoryDelete)
mux.HandleFunc("GET /admin/orders", admin.OrderList)
mux.HandleFunc("POST /admin/orders/{id}/status", admin.OrderUpdateStatus)
addr := ":" + getEnv("APP_PORT", "8080") addr := ":" + getEnv("APP_PORT", "8080")
srv := &http.Server{ srv := &http.Server{
@@ -107,7 +137,7 @@ func main() {
} }
go func() { go func() {
log.Printf("server listening on %s", addr) log.Printf("Shop %s listening on %s", version.Version, addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err) log.Fatalf("listen: %v", err)
} }
+7
View File
@@ -14,6 +14,9 @@ services:
- ./migrations/002_admins.sql:/docker-entrypoint-initdb.d/002_admins.sql:ro - ./migrations/002_admins.sql:/docker-entrypoint-initdb.d/002_admins.sql:ro
- ./migrations/003_admin_features.sql:/docker-entrypoint-initdb.d/003_admin_features.sql:ro - ./migrations/003_admin_features.sql:/docker-entrypoint-initdb.d/003_admin_features.sql:ro
- ./migrations/004_users_cart_orders.sql:/docker-entrypoint-initdb.d/004_users_cart_orders.sql:ro - ./migrations/004_users_cart_orders.sql:/docker-entrypoint-initdb.d/004_users_cart_orders.sql:ro
- ./migrations/005_categories_sale.sql:/docker-entrypoint-initdb.d/005_categories_sale.sql:ro
- ./migrations/006_product_attrs_history.sql:/docker-entrypoint-initdb.d/006_product_attrs_history.sql:ro
- ./migrations/007_heleket.sql:/docker-entrypoint-initdb.d/007_heleket.sql:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"]
interval: 5s interval: 5s
@@ -26,6 +29,9 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
APP_VERSION: ${APP_VERSION:-dev}
BUILD_TIME: ${BUILD_TIME:-}
container_name: shop-app container_name: shop-app
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
@@ -34,6 +40,7 @@ services:
DB_PORT: "5432" DB_PORT: "5432"
APP_PORT: "8080" APP_PORT: "8080"
UPLOAD_DIR: /app/data/uploads UPLOAD_DIR: /app/data/uploads
APP_VERSION: ${APP_VERSION:-dev}
volumes: volumes:
- uploads:/app/data/uploads - uploads:/app/data/uploads
depends_on: depends_on:
+66
View File
@@ -0,0 +1,66 @@
package compare
import (
"strconv"
"strings"
)
const maxItems = 2
func Parse(raw string) []int {
if raw == "" {
return nil
}
var ids []int
for _, part := range strings.Split(raw, ",") {
id, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || id <= 0 {
continue
}
ids = appendUnique(ids, id)
}
return ids
}
func Format(ids []int) string {
if len(ids) == 0 {
return ""
}
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.Itoa(id)
}
return strings.Join(parts, ",")
}
func Add(raw string, id int) string {
if id <= 0 {
return raw
}
ids := Parse(raw)
ids = appendUnique(ids, id)
if len(ids) > maxItems {
ids = ids[len(ids)-maxItems:]
}
return Format(ids)
}
func Remove(raw string, id int) string {
ids := Parse(raw)
var next []int
for _, v := range ids {
if v != id {
next = append(next, v)
}
}
return Format(next)
}
func appendUnique(ids []int, id int) []int {
for _, v := range ids {
if v == id {
return ids
}
}
return append(ids, id)
}
+21 -8
View File
@@ -5,21 +5,34 @@ import (
"encoding/hex" "encoding/hex"
"log" "log"
"os" "os"
"strings"
) )
type Config struct { type Config struct {
AdminEmail string AdminEmail string
AdminPassword string AdminPassword string
SessionSecret string SessionSecret string
UploadDir string UploadDir string
PublicBaseURL string
HeleketMerchantID string
HeleketPaymentAPIKey string
HeleketCurrency string
} }
func Load() Config { func Load() Config {
cfg := Config{ cfg := Config{
AdminEmail: os.Getenv("ADMIN_EMAIL"), AdminEmail: os.Getenv("ADMIN_EMAIL"),
AdminPassword: os.Getenv("ADMIN_PASSWORD"), AdminPassword: os.Getenv("ADMIN_PASSWORD"),
SessionSecret: os.Getenv("SESSION_SECRET"), SessionSecret: os.Getenv("SESSION_SECRET"),
UploadDir: os.Getenv("UPLOAD_DIR"), UploadDir: os.Getenv("UPLOAD_DIR"),
PublicBaseURL: strings.TrimRight(os.Getenv("PUBLIC_BASE_URL"), "/"),
HeleketMerchantID: os.Getenv("HELEKET_MERCHANT_ID"),
HeleketPaymentAPIKey: os.Getenv("HELEKET_PAYMENT_API_KEY"),
HeleketCurrency: os.Getenv("HELEKET_CURRENCY"),
}
if cfg.HeleketCurrency == "" {
cfg.HeleketCurrency = "RUB"
} }
if cfg.UploadDir == "" { if cfg.UploadDir == "" {
+49
View File
@@ -77,6 +77,55 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
quantity INT NOT NULL CHECK (quantity > 0) quantity INT NOT NULL CHECK (quantity > 0)
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id)`, `CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id)`,
`CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INT REFERENCES categories(id) ON DELETE SET NULL`,
`ALTER TABLE products ADD COLUMN IF NOT EXISTS sale_price NUMERIC(10,2)`,
`INSERT INTO categories (name, slug, sort_order) VALUES
('Электроника', 'elektronika', 1),
('Одежда', 'odezhda', 2),
('Дом и сад', 'dom-i-sad', 3),
('Аксессуары', 'aksessuary', 4),
('Разное', 'raznoe', 5)
ON CONFLICT (slug) DO NOTHING`,
`UPDATE products p SET category_id = c.id
FROM categories c WHERE p.category = c.name AND p.category_id IS NULL`,
`ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_size VARCHAR(50) NOT NULL DEFAULT ''`,
`ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_color VARCHAR(50) NOT NULL DEFAULT ''`,
`ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_material VARCHAR(100) NOT NULL DEFAULT ''`,
`ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_weight VARCHAR(50) NOT NULL DEFAULT ''`,
`ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_brand VARCHAR(100) NOT NULL DEFAULT ''`,
`CREATE TABLE IF NOT EXISTS product_price_history (
id SERIAL PRIMARY KEY,
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
price NUMERIC(10,2) NOT NULL,
sale_price NUMERIC(10,2),
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_price_history_product ON product_price_history (product_id, recorded_at DESC)`,
`CREATE TABLE IF NOT EXISTS product_reservations (
id SERIAL PRIMARY KEY,
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
session_key VARCHAR(64) NOT NULL DEFAULT '',
user_id INT REFERENCES users(id) ON DELETE SET NULL,
customer_name VARCHAR(255) NOT NULL DEFAULT '',
customer_phone VARCHAR(50) NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_reservations_product ON product_reservations (product_id, expires_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_reservations_session ON product_reservations (session_key)`,
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cod'`,
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_uuid VARCHAR(64) NOT NULL DEFAULT ''`,
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_order_id VARCHAR(128) NOT NULL DEFAULT ''`,
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_status VARCHAR(50) NOT NULL DEFAULT ''`,
} }
for _, q := range queries { for _, q := range queries {
+33 -17
View File
@@ -13,12 +13,15 @@ import (
) )
type AdminHandler struct { type AdminHandler struct {
repo *repository.AdminRepository repo *repository.AdminRepository
products *repository.ProductRepository products *repository.ProductRepository
stats *repository.StatsRepository categories *repository.CategoryRepository
storage *upload.Storage orders *repository.OrderRepository
session *auth.Session priceHistory *repository.PriceHistoryRepository
templates *template.Template stats *repository.StatsRepository
storage *upload.Storage
session *auth.Session
templates *template.Template
} }
type adminLayoutData struct { type adminLayoutData struct {
@@ -52,28 +55,41 @@ type adminProductsData struct {
} }
type adminProductFormData struct { type adminProductFormData struct {
Title string Title string
Email string Email string
Product models.Product Product models.Product
IsNew bool Categories []models.Category
Error string IsNew bool
Error string
}
type adminCategoriesData struct {
Title string
Email string
Categories []models.Category
} }
func NewAdminHandler( func NewAdminHandler(
adminRepo *repository.AdminRepository, adminRepo *repository.AdminRepository,
productRepo *repository.ProductRepository, productRepo *repository.ProductRepository,
categoryRepo *repository.CategoryRepository,
orderRepo *repository.OrderRepository,
priceHistory *repository.PriceHistoryRepository,
statsRepo *repository.StatsRepository, statsRepo *repository.StatsRepository,
storage *upload.Storage, storage *upload.Storage,
session *auth.Session, session *auth.Session,
templates *template.Template, templates *template.Template,
) *AdminHandler { ) *AdminHandler {
return &AdminHandler{ return &AdminHandler{
repo: adminRepo, repo: adminRepo,
products: productRepo, products: productRepo,
stats: statsRepo, categories: categoryRepo,
storage: storage, orders: orderRepo,
session: session, priceHistory: priceHistory,
templates: templates, 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
}
+88
View File
@@ -0,0 +1,88 @@
package handlers
import (
"log"
"net/http"
"strconv"
"shop/internal/models"
"shop/internal/orderstatus"
)
type adminOrdersData struct {
Title string
Email string
Orders []models.Order
StatusList []orderStatusOption
Success string
}
type orderStatusOption struct {
Code string
Label string
}
func statusOptions() []orderStatusOption {
var list []orderStatusOption
for _, code := range orderstatus.AllCodes {
list = append(list, orderStatusOption{Code: code, Label: orderstatus.Label(code)})
}
return list
}
func (h *AdminHandler) OrderList(w http.ResponseWriter, r *http.Request) {
email, ok := h.requireAuth(w, r)
if !ok {
return
}
orders, err := h.orders.All(r.Context(), 200)
if err != nil {
log.Printf("admin orders: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
success := r.URL.Query().Get("ok")
msg := ""
if success == "1" {
msg = "Статус заказа обновлён"
}
h.render(w, "admin_orders.html", adminOrdersData{
Title: "Заказы",
Email: email,
Orders: orders,
StatusList: statusOptions(),
Success: msg,
})
}
func (h *AdminHandler) OrderUpdateStatus(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
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
status := r.FormValue("status")
if !orderstatus.Valid(status) {
http.Redirect(w, r, "/admin/orders", http.StatusSeeOther)
return
}
if err := h.orders.UpdateStatus(r.Context(), id, status); err != nil {
log.Printf("order status: %v", err)
}
http.Redirect(w, r, "/admin/orders?ok=1", http.StatusSeeOther)
}
+46 -20
View File
@@ -35,10 +35,11 @@ func (h *AdminHandler) ProductNew(w http.ResponseWriter, r *http.Request) {
} }
h.render(w, "admin_product_form.html", adminProductFormData{ h.render(w, "admin_product_form.html", adminProductFormData{
Title: "Новый товар", Title: "Новый товар",
Email: email, Email: email,
IsNew: true, IsNew: true,
Product: models.Product{IsActive: true, Category: "Разное"}, Categories: h.loadCategories(r.Context()),
Product: models.Product{IsActive: true},
}) })
} }
@@ -61,9 +62,10 @@ func (h *AdminHandler) ProductEdit(w http.ResponseWriter, r *http.Request) {
} }
h.render(w, "admin_product_form.html", adminProductFormData{ h.render(w, "admin_product_form.html", adminProductFormData{
Title: "Редактирование товара", Title: "Редактирование товара",
Email: email, Email: email,
Product: product, Product: product,
Categories: h.loadCategories(r.Context()),
}) })
} }
@@ -82,25 +84,45 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
productID, _ := strconv.Atoi(r.FormValue("id")) productID, _ := strconv.Atoi(r.FormValue("id"))
price, _ := strconv.ParseFloat(r.FormValue("price"), 64) price, _ := strconv.ParseFloat(r.FormValue("price"), 64)
salePrice, _ := strconv.ParseFloat(r.FormValue("sale_price"), 64)
categoryID, _ := strconv.Atoi(r.FormValue("category_id"))
var catID *int
var categoryName string
if categoryID > 0 {
catID = &categoryID
if cat, err := h.categories.GetByID(ctx, categoryID); err == nil {
categoryName = cat.Name
}
}
product := models.Product{ product := models.Product{
ID: productID, ID: productID,
Name: r.FormValue("name"), Name: r.FormValue("name"),
Description: r.FormValue("description"), Description: r.FormValue("description"),
Details: r.FormValue("details"), Details: r.FormValue("details"),
Price: price, Price: price,
Category: r.FormValue("category"), SalePrice: salePrice,
IsActive: r.FormValue("is_active") == "on", Category: categoryName,
ImageURL: r.FormValue("image_url"), CategoryID: catID,
IsActive: r.FormValue("is_active") == "on",
ImageURL: r.FormValue("image_url"),
AttrSize: r.FormValue("attr_size"),
AttrColor: r.FormValue("attr_color"),
AttrMaterial: r.FormValue("attr_material"),
AttrWeight: r.FormValue("attr_weight"),
AttrBrand: r.FormValue("attr_brand"),
} }
if product.Name == "" || product.Price < 0 { if product.Name == "" || product.Price < 0 {
email, _ := h.requireAuth(w, r) email, _ := h.requireAuth(w, r)
h.render(w, "admin_product_form.html", adminProductFormData{ h.render(w, "admin_product_form.html", adminProductFormData{
Title: "Ошибка", Title: "Ошибка",
Email: email, Email: email,
Product: product, Product: product,
IsNew: isNew, Categories: h.loadCategories(ctx),
Error: "Заполните название и цену", IsNew: isNew,
Error: "Заполните название и цену",
}) })
return return
} }
@@ -111,6 +133,7 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
_ = h.priceHistory.Record(ctx, product.ID, product.Price, product.SalePrice)
} else { } else {
existing, err := h.products.GetByID(ctx, product.ID) existing, err := h.products.GetByID(ctx, product.ID)
if err != nil { if err != nil {
@@ -125,6 +148,9 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
if existing.Price != product.Price || existing.SalePrice != product.SalePrice {
_ = h.priceHistory.Record(ctx, product.ID, product.Price, product.SalePrice)
}
} }
if err := h.saveUploadedImages(r, product.ID, true); err != nil { if err := h.saveUploadedImages(r, product.ID, true); err != nil {
+210 -27
View File
@@ -1,31 +1,45 @@
package handlers package handlers
import ( import (
"fmt"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"shop/internal/auth" "shop/internal/auth"
"shop/internal/compare"
"shop/internal/heleket"
"shop/internal/models" "shop/internal/models"
"shop/internal/repository" "shop/internal/repository"
"shop/internal/version"
) )
type CustomerHandler struct { type CustomerHandler struct {
products *repository.ProductRepository products *repository.ProductRepository
categories *repository.CategoryRepository
users *repository.UserRepository users *repository.UserRepository
cart *repository.CartRepository cart *repository.CartRepository
orders *repository.OrderRepository orders *repository.OrderRepository
stats *repository.StatsRepository priceHistory *repository.PriceHistoryRepository
userSession *auth.Session reservations *repository.ReservationRepository
cartSession *auth.Session stats *repository.StatsRepository
templates *template.Template userSession *auth.Session
cartSession *auth.Session
compareSession *auth.Session
heleket *heleket.Client
heleketAPIKey string
publicBaseURL string
heleketCurrency string
templates *template.Template
} }
type shopPage struct { type shopPage struct {
Title string Title string
User *models.User User *models.User
CartCount int CartCount int
CompareCount int
Version string
Products interface{} Products interface{}
Categories interface{} Categories interface{}
Error string Error string
@@ -40,46 +54,81 @@ type cartPage struct {
type checkoutPage struct { type checkoutPage struct {
shopPage shopPage
Items []models.CartItem Items []models.CartItem
Total float64 Total float64
Name string Name string
Email string Email string
Phone string Phone string
Address string Address string
HeleketEnabled bool
} }
type accountPage struct { type accountPage struct {
shopPage shopPage
Orders []models.Order Orders []models.Order
Name string Name string
Phone string Phone string
Address string Address string
} }
type productPage struct {
shopPage
Product models.Product
PriceHistory []models.PriceHistoryEntry
MaxHistoryPrice float64
Reservation *models.Reservation
ReservedByOther bool
CompareProducts []models.Product
CompareCount int
Success string
Error string
}
type comparePage struct {
shopPage
Products []models.Product
}
type categoryPage struct {
shopPage
Category models.Category
Products []models.Product
}
func NewCustomerHandler( func NewCustomerHandler(
products *repository.ProductRepository, products *repository.ProductRepository,
categories *repository.CategoryRepository,
users *repository.UserRepository, users *repository.UserRepository,
cart *repository.CartRepository, cart *repository.CartRepository,
orders *repository.OrderRepository, orders *repository.OrderRepository,
priceHistory *repository.PriceHistoryRepository,
reservations *repository.ReservationRepository,
stats *repository.StatsRepository, stats *repository.StatsRepository,
userSession, cartSession *auth.Session, userSession, cartSession, compareSession *auth.Session,
heleketClient *heleket.Client,
heleketAPIKey, publicBaseURL, heleketCurrency string,
templates *template.Template, templates *template.Template,
) *CustomerHandler { ) *CustomerHandler {
return &CustomerHandler{ return &CustomerHandler{
products: products, users: users, cart: cart, orders: orders, stats: stats, products: products, categories: categories, users: users, cart: cart, orders: orders,
userSession: userSession, cartSession: cartSession, templates: templates, priceHistory: priceHistory, reservations: reservations, stats: stats,
userSession: userSession, cartSession: cartSession, compareSession: compareSession,
heleket: heleketClient, heleketAPIKey: heleketAPIKey,
publicBaseURL: publicBaseURL, heleketCurrency: heleketCurrency,
templates: templates,
} }
} }
func (h *CustomerHandler) render(w http.ResponseWriter, name string, data any) { func (h *CustomerHandler) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := h.templates.ExecuteTemplate(w, name, data); err != nil { if err := h.templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("render %s: %v", name, err) log.Printf("render %s: %v", name, err)
} }
} }
func (h *CustomerHandler) basePage(r *http.Request, w http.ResponseWriter, title string) shopPage { func (h *CustomerHandler) basePage(r *http.Request, w http.ResponseWriter, title string) shopPage {
p := shopPage{Title: title} p := shopPage{Title: title, Version: version.Version}
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok { if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
if u, err := h.users.GetByID(r.Context(), uid); err == nil { if u, err := h.users.GetByID(r.Context(), uid); err == nil {
p.User = &u p.User = &u
@@ -91,6 +140,7 @@ func (h *CustomerHandler) basePage(r *http.Request, w http.ResponseWriter, title
p.CartCount = n p.CartCount = n
} }
} }
p.CompareCount = h.compareCount(r)
return p return p
} }
@@ -117,6 +167,89 @@ func (h *CustomerHandler) Home(w http.ResponseWriter, r *http.Request) {
h.render(w, "home.html", p) h.render(w, "home.html", p)
} }
func (h *CustomerHandler) ProductPage(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
ctx := r.Context()
product, err := h.products.GetActiveByID(ctx, id)
if err != nil {
http.NotFound(w, r)
return
}
p := productPage{
shopPage: h.basePage(r, w, product.Name),
Product: product,
}
p.PriceHistory, _ = h.priceHistory.ByProduct(ctx, id, 12)
for _, e := range p.PriceHistory {
if e.Price > p.MaxHistoryPrice {
p.MaxHistoryPrice = e.Price
}
}
sessionKey := h.cartSession.EnsureKey(w, r)
if res, err := h.reservations.ActiveByProduct(ctx, id); err == nil {
p.Reservation = res
if res.SessionKey != sessionKey {
var uid int
if u, ok := auth.UserIDFromSession(h.userSession, r); ok {
uid = u
}
if res.UserID == nil || uid == 0 || *res.UserID != uid {
p.ReservedByOther = true
}
}
}
compareRaw, _ := h.compareSession.FromRequest(r)
compareIDs := compare.Parse(compareRaw)
p.CompareCount = len(compareIDs)
var others []models.Product
if product.CategoryID != nil {
others, _ = h.products.ByCategoryID(ctx, *product.CategoryID)
} else {
others, _ = h.products.Featured(ctx, 50)
}
for _, o := range others {
if o.ID != id {
p.CompareProducts = append(p.CompareProducts, o)
}
}
switch r.URL.Query().Get("ok") {
case "reserve":
p.Success = "Товар забронирован на 24 часа"
case "compare":
p.Success = "Товар добавлен к сравнению"
}
if r.URL.Query().Get("err") == "reserved" {
p.Error = "Товар уже забронирован другим покупателем"
}
h.render(w, "product.html", p)
}
func (h *CustomerHandler) CategoryPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
cat, err := h.categories.GetBySlug(r.Context(), slug)
if err != nil {
http.NotFound(w, r)
return
}
products, _ := h.products.ByCategoryID(r.Context(), cat.ID)
p := categoryPage{
shopPage: h.basePage(r, w, cat.Name),
Category: cat,
Products: products,
}
h.render(w, "category.html", p)
}
func (h *CustomerHandler) RegisterPage(w http.ResponseWriter, r *http.Request) { func (h *CustomerHandler) RegisterPage(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.UserIDFromSession(h.userSession, r); ok { if _, ok := auth.UserIDFromSession(h.userSession, r); ok {
http.Redirect(w, r, "/account", http.StatusSeeOther) http.Redirect(w, r, "/account", http.StatusSeeOther)
@@ -270,9 +403,10 @@ func (h *CustomerHandler) CheckoutPage(w http.ResponseWriter, r *http.Request) {
} }
total, _ := h.cart.Total(ctx, cartID) total, _ := h.cart.Total(ctx, cartID)
p := checkoutPage{ p := checkoutPage{
shopPage: h.basePage(r, w, "Оформление заказа"), shopPage: h.basePage(r, w, "Оформление заказа"),
Items: items, Items: items,
Total: total, Total: total,
HeleketEnabled: h.heleket.Enabled(),
} }
if p.User != nil { if p.User != nil {
p.Name = p.User.Name p.Name = p.User.Name
@@ -303,9 +437,14 @@ func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) {
address := r.FormValue("address") address := r.FormValue("address")
p := checkoutPage{ p := checkoutPage{
shopPage: h.basePage(r, w, "Оформление заказа"), shopPage: h.basePage(r, w, "Оформление заказа"),
Items: items, Total: total, Items: items,
Name: name, Email: email, Phone: phone, Address: address, Total: total,
Name: name,
Email: email,
Phone: phone,
Address: address,
HeleketEnabled: h.heleket.Enabled(),
} }
if name == "" || email == "" || phone == "" || address == "" { if name == "" || email == "" || phone == "" || address == "" {
p.Error = "Заполните все поля доставки" p.Error = "Заполните все поля доставки"
@@ -313,9 +452,22 @@ func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) {
return return
} }
paymentMethod := r.FormValue("payment_method")
if paymentMethod != "heleket" {
paymentMethod = "cod"
}
if paymentMethod == "heleket" && !h.heleket.Enabled() {
p.Error = "Онлайн-оплата временно недоступна"
h.render(w, "checkout.html", p)
return
}
order := models.Order{ order := models.Order{
GuestName: name, GuestEmail: email, GuestPhone: phone, GuestName: name, GuestEmail: email, GuestPhone: phone,
Address: address, Total: total, Address: address, Total: total, PaymentMethod: paymentMethod,
}
if paymentMethod == "heleket" {
order.Status = "unpaid"
} }
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok { if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
order.UserID = &uid order.UserID = &uid
@@ -328,6 +480,36 @@ func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) {
h.render(w, "checkout.html", p) h.render(w, "checkout.html", p)
return return
} }
if paymentMethod == "heleket" {
externalID := heleket.OrderExternalID(order.ID)
base := h.publicBaseURL
if base == "" {
base = "http://" + r.Host
}
invoice, err := h.heleket.CreateInvoice(ctx, heleket.InvoiceRequest{
Amount: heleket.FormatAmount(total),
Currency: h.heleketCurrency,
OrderID: externalID,
URLReturn: base + "/cart",
URLSuccess: base + "/order/" + strconv.Itoa(order.ID) + "/success",
URLCallback: base + "/webhooks/heleket",
PayerEmail: email,
Lifetime: 3600,
AdditionalData: "order:" + strconv.Itoa(order.ID),
})
if err != nil {
log.Printf("heleket invoice: %v", err)
p.Error = "Заказ #" + strconv.Itoa(order.ID) + " создан, но счёт не выдан. Попробуйте оплатить из личного кабинета."
h.render(w, "checkout.html", p)
return
}
_ = h.orders.UpdateHeleketPayment(ctx, order.ID, invoice.UUID, externalID, invoice.URL, invoice.PaymentStatus)
_ = h.cart.Clear(ctx, cartID)
http.Redirect(w, r, invoice.URL, http.StatusSeeOther)
return
}
_ = h.cart.Clear(ctx, cartID) _ = h.cart.Clear(ctx, cartID)
success := h.basePage(r, w, "Заказ оформлен") success := h.basePage(r, w, "Заказ оформлен")
@@ -373,5 +555,6 @@ func (h *CustomerHandler) Account(w http.ResponseWriter, r *http.Request) {
func (h *CustomerHandler) Health(w http.ResponseWriter, r *http.Request) { func (h *CustomerHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`)) w.Header().Set("Cache-Control", "no-store")
fmt.Fprintf(w, `{"status":"ok","version":%q}`, version.Version)
} }
+162
View File
@@ -0,0 +1,162 @@
package handlers
import (
"log"
"net/http"
"strconv"
"time"
"shop/internal/auth"
"shop/internal/compare"
"shop/internal/models"
)
func (h *CustomerHandler) BuyNow(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, "/checkout", http.StatusSeeOther)
}
func (h *CustomerHandler) ProductReserve(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
ctx := r.Context()
if _, err := h.products.GetActiveByID(ctx, id); err != nil {
http.NotFound(w, r)
return
}
sessionKey := h.cartSession.EnsureKey(w, r)
if active, err := h.reservations.ActiveByProduct(ctx, id); err == nil {
if active.SessionKey != sessionKey {
var uid int
if u, ok := auth.UserIDFromSession(h.userSession, r); ok {
uid = u
}
if active.UserID == nil || uid == 0 || *active.UserID != uid {
http.Redirect(w, r, "/product/"+strconv.Itoa(id)+"?err=reserved", http.StatusSeeOther)
return
}
}
}
name := r.FormValue("name")
phone := r.FormValue("phone")
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
if u, err := h.users.GetByID(ctx, uid); err == nil {
if name == "" {
name = u.Name
}
if phone == "" {
phone = u.Phone
}
}
}
if name == "" {
name = "Гость"
}
res := models.Reservation{
ProductID: id,
SessionKey: sessionKey,
CustomerName: name,
CustomerPhone: phone,
ExpiresAt: time.Now().Add(h.reservations.ExpiresIn()),
}
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
res.UserID = &uid
}
if err := h.reservations.ReplaceForProduct(ctx, &res); err != nil {
log.Printf("reserve: %v", err)
http.Redirect(w, r, "/product/"+strconv.Itoa(id), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/product/"+strconv.Itoa(id)+"?ok=reserve", http.StatusSeeOther)
}
func (h *CustomerHandler) CompareAdd(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"))
otherID, _ := strconv.Atoi(r.FormValue("other_id"))
redirect := r.FormValue("redirect")
if redirect == "" {
redirect = "/compare"
}
raw, _ := h.compareSession.FromRequest(r)
next := raw
if productID > 0 {
next = compare.Add(next, productID)
}
if otherID > 0 {
next = compare.Add(next, otherID)
}
if next == raw && productID <= 0 {
http.Redirect(w, r, redirect, http.StatusSeeOther)
return
}
token, _ := h.compareSession.Create(next)
h.compareSession.SetCookie(w, r, token)
http.Redirect(w, r, redirect, http.StatusSeeOther)
}
func (h *CustomerHandler) CompareRemove(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"))
raw, _ := h.compareSession.FromRequest(r)
next := compare.Remove(raw, productID)
token, _ := h.compareSession.Create(next)
h.compareSession.SetCookie(w, r, token)
http.Redirect(w, r, "/compare", http.StatusSeeOther)
}
func (h *CustomerHandler) ComparePage(w http.ResponseWriter, r *http.Request) {
raw, _ := h.compareSession.FromRequest(r)
ids := compare.Parse(raw)
ctx := r.Context()
var products []models.Product
for _, id := range ids {
if p, err := h.products.GetActiveByID(ctx, id); err == nil {
products = append(products, p)
}
}
p := comparePage{
shopPage: h.basePage(r, w, "Сравнение товаров"),
Products: products,
}
h.render(w, "compare.html", p)
}
func (h *CustomerHandler) compareCount(r *http.Request) int {
raw, _ := h.compareSession.FromRequest(r)
return len(compare.Parse(raw))
}
+35 -1
View File
@@ -7,6 +7,9 @@ import (
"log" "log"
"net/http" "net/http"
"regexp" "regexp"
"shop/internal/models"
"shop/internal/orderstatus"
) )
//go:embed templates/* //go:embed templates/*
@@ -21,9 +24,40 @@ func stripHTML(s string) string {
return tagRe.ReplaceAllString(s, "") return tagRe.ReplaceAllString(s, "")
} }
func effectivePrice(p models.Product) float64 { return p.EffectivePrice() }
func ParseTemplates() (*template.Template, error) { func ParseTemplates() (*template.Template, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"plain": stripHTML, "plain": stripHTML,
"effective": effectivePrice,
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
"orderLabel": orderstatus.Label,
"orderIcon": orderstatus.Icon,
"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)
},
"orderTerminal": orderstatus.IsTerminal,
"orderNorm": orderstatus.Normalize,
"historyBarPct": func(price, max float64) int {
if max <= 0 {
return 20
}
pct := int(price / max * 100)
if pct < 8 {
return 8
}
return pct
},
"catSelected": func(catID int, productCatID *int) bool {
return productCatID != nil && *productCatID == catID
},
} }
return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html") return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
} }
+110
View File
@@ -0,0 +1,110 @@
package handlers
import (
"io"
"log"
"net"
"net/http"
"strconv"
"shop/internal/heleket"
"shop/internal/models"
)
const heleketWebhookIP = "31.133.220.8"
func (h *CustomerHandler) HeleketWebhook(w http.ResponseWriter, r *http.Request) {
if !h.heleket.Enabled() {
http.NotFound(w, r)
return
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
host = fwd
}
if host != heleketWebhookIP && host != "" {
log.Printf("heleket webhook: note ip %s (expected %s)", host, heleketWebhookIP)
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
payload, ok := heleket.VerifyWebhook(body, h.heleketAPIKey)
if !ok {
log.Printf("heleket webhook: invalid signature")
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
orderIDStr, _ := payload["order_id"].(string)
orderID, ok := heleket.ParseOrderExternalID(orderIDStr)
if !ok {
log.Printf("heleket webhook: unknown order_id %q", orderIDStr)
w.WriteHeader(http.StatusOK)
return
}
paymentStatus, _ := payload["payment_status"].(string)
if paymentStatus == "" {
if s, ok := payload["status"].(string); ok {
paymentStatus = s
}
}
ctx := r.Context()
orderStatus := heleket.MapPaymentStatus(paymentStatus)
if err := h.orders.UpdateHeleketStatus(ctx, orderID, paymentStatus, orderStatus); err != nil {
log.Printf("heleket webhook update: %v", err)
}
w.WriteHeader(http.StatusOK)
}
type orderPayPage struct {
shopPage
Order models.Order
}
func (h *CustomerHandler) OrderSuccess(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
order, err := h.orders.GetByID(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
p := orderPayPage{
shopPage: h.basePage(r, w, "Заказ #"+strconv.Itoa(order.ID)),
Order: order,
}
h.render(w, "order_success.html", p)
}
func (h *CustomerHandler) OrderPay(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
order, err := h.orders.GetByID(r.Context(), id)
if err != nil || !order.IsHeleket() {
http.NotFound(w, r)
return
}
if order.HeleketPaymentURL != "" && !heleket.IsPaid(order.HeleketPaymentStatus) {
http.Redirect(w, r, order.HeleketPaymentURL, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/order/"+strconv.Itoa(id)+"/success", http.StatusSeeOther)
}
+56
View File
@@ -188,6 +188,55 @@
color: #9ca3af; color: #9ca3af;
} }
.admin-alert--ok {
background: #d1fae5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.admin-table__muted {
font-size: 0.8rem;
color: #9ca3af;
}
.admin-table--orders td {
vertical-align: top;
}
.admin-order-details td {
background: #f9fafb;
font-size: 0.85rem;
color: #6b7280;
padding-top: 0 !important;
}
.admin-order-items {
margin: 8px 0 0;
padding-left: 18px;
}
.admin-status-form {
display: flex;
gap: 8px;
align-items: center;
}
.admin-form__input--sm {
padding: 8px 10px;
font-size: 0.85rem;
min-width: 140px;
}
.admin-status-legend {
margin-top: 32px;
}
.admin-status-legend__grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.admin-hint code { .admin-hint code {
background: #e5e7eb; background: #e5e7eb;
padding: 2px 6px; padding: 2px 6px;
@@ -336,6 +385,13 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.admin-section__subtitle {
font-size: 0.95rem;
font-weight: 600;
margin: 20px 0 8px;
color: #374151;
}
.admin-editor { .admin-editor {
min-height: 160px; min-height: 160px;
background: #fff; background: #fff;
+467
View File
@@ -250,7 +250,474 @@
margin: 0; margin: 0;
} }
/* Цены со скидкой */
.price-block {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.price-block__old {
text-decoration: line-through;
color: #9ca3af;
font-size: 0.9rem;
}
.price-block__sale,
.price-block__current {
font-size: 1.25rem;
font-weight: 700;
color: #ef4444;
}
.price-block--lg .price-block__sale,
.price-block--lg .price-block__current {
font-size: 2rem;
}
.price-block__badge {
background: #fef2f2;
color: #b91c1c;
padding: 4px 10px;
border-radius: 100px;
font-size: 0.85rem;
font-weight: 600;
}
/* Страница товара */
.breadcrumb {
display: flex;
gap: 8px;
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 24px;
flex-wrap: wrap;
}
.breadcrumb a {
color: #4f46e5;
text-decoration: none;
}
.product-detail {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
margin-bottom: 40px;
}
.product-detail__main {
width: 100%;
border-radius: 16px;
border: 1px solid #e5e7eb;
}
.product-detail__thumbs {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.product-detail__thumbs img {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #e5e7eb;
cursor: pointer;
}
.product-detail__cat {
color: #4f46e5;
text-decoration: none;
font-size: 0.875rem;
font-weight: 600;
}
.product-detail__info h1 {
font-size: 2rem;
margin: 12px 0 20px;
}
.product-detail__short {
color: #6b7280;
margin: 20px 0;
line-height: 1.6;
}
.product-detail__buy {
display: flex;
gap: 12px;
align-items: center;
}
.qty-input {
width: 72px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 10px;
font-size: 1rem;
}
.product-detail__html {
line-height: 1.7;
color: #374151;
}
.product-detail__html h1,
.product-detail__html h2,
.product-detail__html h3 {
margin: 1em 0 0.5em;
}
/* Статусы заказов */
.order-card__head {
justify-content: space-between;
}
.order-card__date {
display: block;
font-size: 0.8rem;
color: #9ca3af;
margin-top: 2px;
}
.order-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 100px;
font-size: 0.8rem;
font-weight: 600;
}
.status--pending { background: #fef3c7; color: #b45309; }
.status--unpaid { background: #fee2e2; color: #b91c1c; }
.status--paid { background: #dbeafe; color: #1d4ed8; }
.status--new { background: #fef3c7; color: #b45309; }
.status--confirmed { background: #dbeafe; color: #1d4ed8; }
.status--processing { background: #ede9fe; color: #6d28d9; }
.status--shipped { background: #ffedd5; color: #c2410c; }
.status--delivered { background: #d1fae5; color: #065f46; }
.status--cancelled { background: #f3f4f6; color: #6b7280; }
.status--refunded { background: #fce7f3; color: #be185d; }
.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;
}
/* Иконки статусов */
.order-status__icon {
font-size: 0.95em;
line-height: 1;
}
/* Характеристики товара */
.product-specs {
display: grid;
gap: 8px;
margin: 16px 0;
padding: 16px;
background: #f9fafb;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.product-specs__row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
font-size: 0.9rem;
}
.product-specs__row dt {
color: #6b7280;
margin: 0;
}
.product-specs__row dd {
margin: 0;
font-weight: 600;
color: #111827;
}
/* Кнопки товара */
.product-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 20px 0;
}
.product-actions__form {
display: flex;
gap: 8px;
align-items: center;
}
.product-reserve {
margin: 20px 0;
padding: 16px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 12px;
}
.product-reserve__row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.shop-input {
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
min-width: 140px;
}
.shop-input--sm {
min-width: 200px;
padding: 8px 10px;
}
.btn--secondary {
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.btn--secondary:hover {
background: #fde68a;
}
.shop-alert--warn {
background: #fffbeb;
color: #92400e;
border: 1px solid #fcd34d;
}
.product-compare {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
.product-compare__form {
display: flex;
gap: 8px;
align-items: center;
}
/* История цены */
.product-price-history {
margin-top: 32px;
}
.price-history-chart {
display: flex;
align-items: flex-end;
gap: 12px;
height: 160px;
margin: 20px 0;
padding: 0 8px;
}
.price-history-chart__bar {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
min-width: 48px;
}
.price-history-chart__fill {
width: 100%;
max-width: 48px;
background: linear-gradient(180deg, #818cf8, #4f46e5);
border-radius: 6px 6px 0 0;
min-height: 8px;
}
.price-history-chart__value {
font-size: 0.7rem;
color: #4f46e5;
font-weight: 600;
margin-bottom: 4px;
}
.price-history-chart__label {
font-size: 0.7rem;
color: #9ca3af;
margin-top: 6px;
}
.price-history-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.price-history-table th,
.price-history-table td {
padding: 10px 12px;
border-bottom: 1px solid #e5e7eb;
text-align: left;
}
.price-history-table th {
color: #6b7280;
font-weight: 600;
}
/* Сравнение */
.compare-table-wrap {
overflow-x: auto;
margin-top: 24px;
}
.compare-table {
width: 100%;
border-collapse: collapse;
min-width: 600px;
}
.compare-table th,
.compare-table td {
padding: 14px 16px;
border: 1px solid #e5e7eb;
vertical-align: top;
}
.compare-table th {
background: #f9fafb;
}
.compare-table__img {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 8px;
}
.compare-table__remove {
margin-top: 8px;
}
.compare-table__spec td:first-child {
font-weight: 600;
color: #6b7280;
background: #f9fafb;
}
.checkout-payment-title {
margin: 24px 0 12px;
font-size: 1rem;
}
.checkout-payment {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.checkout-payment__option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border: 2px solid #e5e7eb;
border-radius: 12px;
cursor: pointer;
transition: border-color 0.15s;
}
.checkout-payment__option:has(input:checked) {
border-color: #4f46e5;
background: #eef2ff;
}
.checkout-payment__label {
display: flex;
flex-direction: column;
gap: 2px;
}
.checkout-payment__label small {
color: #6b7280;
font-size: 0.85rem;
}
.order-card__payment {
font-size: 0.85rem;
color: #6b7280;
margin-top: 8px;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.product-detail {
grid-template-columns: 1fr;
}
.cart-layout, .cart-layout,
.checkout-layout, .checkout-layout,
.account-layout { .account-layout {
+27 -2
View File
@@ -337,8 +337,33 @@ body {
transition: transform 0.3s; transition: transform 0.3s;
} }
.product-card:hover .product-card__image img { .product-card {
transform: scale(1.05); position: relative;
display: flex;
flex-direction: column;
}
.product-card__link {
text-decoration: none;
color: inherit;
flex: 1;
}
.product-card__cart {
padding: 0 20px 20px;
margin: 0;
}
.product-card__sale {
position: absolute;
top: 12px;
right: 12px;
background: #ef4444;
color: #fff;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 700;
} }
.product-card__badge { .product-card__badge {
@@ -0,0 +1,60 @@
{{define "admin_categories.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body class="admin-body">
{{template "admin_nav" .}}
<main class="admin-main container">
<h1 class="admin-page-title">Категории</h1>
<section class="admin-section">
<h2 class="admin-section__title">Добавить категорию</h2>
<form method="POST" action="/admin/categories" class="admin-form admin-form--row">
<label class="admin-form__label">
Название
<input type="text" name="name" class="admin-form__input" required>
</label>
<label class="admin-form__label">
Описание
<input type="text" name="description" class="admin-form__input">
</label>
<button type="submit" class="btn btn--primary">Добавить</button>
</form>
</section>
<section class="admin-section">
<table class="admin-table">
<thead>
<tr>
<th>Название</th>
<th>Slug</th>
<th>Товаров</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Categories}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td><a href="/category/{{.Slug}}" target="_blank">{{.Slug}}</a></td>
<td>{{.Count}}</td>
<td>
<form method="POST" action="/admin/categories/{{.ID}}/delete" onsubmit="return confirm('Удалить категорию?')">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
</main>
</body>
</html>
{{end}}
@@ -8,6 +8,8 @@
<nav class="admin-nav"> <nav class="admin-nav">
<a href="/admin/" class="admin-nav__link">Главная</a> <a href="/admin/" class="admin-nav__link">Главная</a>
<a href="/admin/products" class="admin-nav__link">Товары</a> <a href="/admin/products" class="admin-nav__link">Товары</a>
<a href="/admin/categories" class="admin-nav__link">Категории</a>
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
<a href="/admin/products/new" class="admin-nav__link admin-nav__link--accent">+ Добавить</a> <a href="/admin/products/new" class="admin-nav__link admin-nav__link--accent">+ Добавить</a>
</nav> </nav>
<div class="admin-header__user"> <div class="admin-header__user">
@@ -0,0 +1,93 @@
{{define "admin_orders.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">
<link rel="stylesheet" href="/static/css/shop.css">
</head>
<body class="admin-body">
{{template "admin_nav" .}}
<main class="admin-main container">
<h1 class="admin-page-title">Заказы</h1>
{{if .Success}}
<div class="admin-alert admin-alert--ok">{{.Success}}</div>
{{end}}
<section class="admin-section">
<table class="admin-table admin-table--orders">
<thead>
<tr>
<th>#</th>
<th>Дата</th>
<th>Клиент</th>
<th>Сумма</th>
<th>Статус</th>
<th>Изменить</th>
</tr>
</thead>
<tbody>
{{range .Orders}}
{{$orderStatus := .Status}}
<tr>
<td><strong>{{.ID}}</strong></td>
<td>{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
<td>
<div>{{.GuestName}}</div>
<div class="admin-table__muted">{{.GuestEmail}}</div>
<div class="admin-table__muted">{{.GuestPhone}}</div>
{{if eq .PaymentMethod "heleket"}}
<div class="admin-table__muted">Heleket {{if .HeleketPaymentStatus}}({{.HeleketPaymentStatus}}){{end}}</div>
{{end}}
</td>
<td>{{printf "%.0f" .Total}} ₽</td>
<td>
<span class="order-status {{orderClass .Status}}"><span class="order-status__icon">{{orderIcon .Status}}</span> {{orderLabel .Status}}</span>
</td>
<td>
<form method="POST" action="/admin/orders/{{.ID}}/status" class="admin-status-form">
<select name="status" class="admin-form__input admin-form__input--sm">
{{range $.StatusList}}
<option value="{{.Code}}" {{if eq .Code (orderNorm $orderStatus)}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
<button type="submit" class="btn btn--primary btn--sm">OK</button>
</form>
</td>
</tr>
<tr class="admin-order-details">
<td colspan="6">
<strong>Адрес:</strong> {{.Address}}
{{if .Items}}
<ul class="admin-order-items">
{{range .Items}}
<li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li>
{{end}}
</ul>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="6" class="admin-empty">Заказов пока нет</td></tr>
{{end}}
</tbody>
</table>
</section>
<div class="admin-status-legend">
<h3 class="admin-section__title">Статусы</h3>
<div class="admin-status-legend__grid">
{{range .StatusList}}
<span class="order-status {{orderClass .Code}}"><span class="order-status__icon">{{orderIcon .Code}}</span> {{.Label}}</span>
{{end}}
</div>
</div>
</main>
</body>
</html>
{{end}}
@@ -44,22 +44,53 @@
<input type="number" name="price" class="admin-form__input" value="{{printf "%.0f" .Product.Price}}" min="0" step="1" required> <input type="number" name="price" class="admin-form__input" value="{{printf "%.0f" .Product.Price}}" min="0" step="1" required>
</label> </label>
<label class="admin-form__label"> <label class="admin-form__label">
Категория Цена со скидкой (₽)
<input type="text" name="category" class="admin-form__input" value="{{.Product.Category}}" list="categories"> <input type="number" name="sale_price" class="admin-form__input" value="{{if .Product.HasSale}}{{printf "%.0f" .Product.SalePrice}}{{end}}" min="0" step="1" placeholder="Без скидки">
<datalist id="categories">
<option value="Электроника">
<option value="Одежда">
<option value="Дом и сад">
<option value="Аксессуары">
<option value="Разное">
</datalist>
</label> </label>
</div> </div>
<label class="admin-form__label">
Категория
<select name="category_id" class="admin-form__input">
<option value="">— без категории —</option>
{{range .Categories}}
<option value="{{.ID}}" {{if catSelected .ID $.Product.CategoryID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label class="admin-form__checkbox"> <label class="admin-form__checkbox">
<input type="checkbox" name="is_active" {{if .Product.IsActive}}checked{{end}}> <input type="checkbox" name="is_active" {{if .Product.IsActive}}checked{{end}}>
Показывать в каталоге Показывать в каталоге
</label> </label>
<h3 class="admin-section__subtitle">Характеристики</h3>
<p class="admin-form__hint">Пустые поля не отображаются на сайте</p>
<div class="admin-form-row">
<label class="admin-form__label">
Бренд
<input type="text" name="attr_brand" class="admin-form__input" value="{{.Product.AttrBrand}}" placeholder="Samsung">
</label>
<label class="admin-form__label">
Размер
<input type="text" name="attr_size" class="admin-form__input" value="{{.Product.AttrSize}}" placeholder="XL / 42">
</label>
</div>
<div class="admin-form-row">
<label class="admin-form__label">
Цвет
<input type="text" name="attr_color" class="admin-form__input" value="{{.Product.AttrColor}}" placeholder="Чёрный">
</label>
<label class="admin-form__label">
Материал
<input type="text" name="attr_material" class="admin-form__input" value="{{.Product.AttrMaterial}}" placeholder="Хлопок">
</label>
</div>
<label class="admin-form__label">
Вес
<input type="text" name="attr_weight" class="admin-form__input" value="{{.Product.AttrWeight}}" placeholder="1.2 кг">
</label>
</section> </section>
<section class="admin-section admin-form-col"> <section class="admin-section admin-form-col">
+263
View File
@@ -0,0 +1,263 @@
{{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>
{{if .Success}}<div class="shop-alert shop-alert--ok">{{.Success}}</div>{{end}}
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
{{if .ReservedByOther}}
<div class="shop-alert shop-alert--warn">
Товар забронирован до {{.Reservation.ExpiresAt.Format "02.01.2006 15:04"}}
</div>
{{else if .Reservation}}
<div class="shop-alert shop-alert--ok">
Вы забронировали этот товар до {{.Reservation.ExpiresAt.Format "02.01.2006 15:04"}}
</div>
{{end}}
<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>
{{$specs := .Product.Specs}}
{{if $specs}}
<dl class="product-specs">
{{range $specs}}
<div class="product-specs__row">
<dt>{{.Label}}</dt>
<dd>{{.Value}}</dd>
</div>
{{end}}
</dl>
{{end}}
<p class="product-detail__short">{{plain .Product.Description}}</p>
<div class="product-actions">
<form method="POST" action="/buy" class="product-actions__form">
<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>
<form method="POST" action="/cart/add" class="product-actions__form">
<input type="hidden" name="product_id" value="{{.Product.ID}}">
<input type="hidden" name="quantity" value="1">
<button type="submit" class="btn btn--ghost btn--lg">В корзину</button>
</form>
</div>
{{if not .ReservedByOther}}
<form method="POST" action="/product/{{.Product.ID}}/reserve" class="product-reserve">
<p class="shop-hint">Забронировать на 24 часа — товар будет отложен для вас</p>
<div class="product-reserve__row">
<input type="text" name="name" placeholder="Имя" class="shop-input">
<input type="tel" name="phone" placeholder="Телефон" class="shop-input">
<button type="submit" class="btn btn--secondary">Забронировать на 24 ч</button>
</div>
</form>
{{end}}
<div class="product-compare">
<form method="POST" action="/compare/add" class="product-compare__form">
<input type="hidden" name="product_id" value="{{.Product.ID}}">
<input type="hidden" name="redirect" value="/compare">
<button type="submit" class="btn btn--ghost btn--sm">⊕ Добавить к сравнению</button>
</form>
{{if .CompareProducts}}
<form method="POST" action="/compare/add" class="product-compare__form">
<input type="hidden" name="product_id" value="{{.Product.ID}}">
<select name="other_id" class="shop-input shop-input--sm" required>
<option value="">Сравнить с другим товаром…</option>
{{range .CompareProducts}}
<option value="{{.ID}}">{{.Name}} — {{printf "%.0f" .EffectivePrice}} ₽</option>
{{end}}
</select>
<input type="hidden" name="redirect" value="/compare">
<button type="submit" class="btn btn--ghost btn--sm">Сравнить</button>
</form>
{{end}}
</div>
</div>
</div>
{{if .PriceHistory}}
<section class="shop-card product-price-history">
<h2>История цены</h2>
<div class="price-history-chart">
{{range .PriceHistory}}
<div class="price-history-chart__bar" title="{{.RecordedAt.Format "02.01.2006"}} {{printf "%.0f" .Price}} ">
<div class="price-history-chart__fill" style="height: {{historyBarPct .Price $.MaxHistoryPrice}}%"></div>
<span class="price-history-chart__value">{{printf "%.0f" .Price}} ₽</span>
<span class="price-history-chart__label">{{.RecordedAt.Format "02.01"}}</span>
</div>
{{end}}
</div>
<table class="price-history-table">
<thead>
<tr><th>Дата</th><th>Цена</th><th>Со скидкой</th></tr>
</thead>
<tbody>
{{range .PriceHistory}}
<tr>
<td>{{.RecordedAt.Format "02.01.2006"}}</td>
<td>{{printf "%.0f" .Price}} ₽</td>
<td>{{if gt .SalePrice 0}}{{printf "%.0f" .SalePrice}} ₽{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{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 "compare.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сравнение товаров — 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">
<h1>Сравнение товаров</h1>
{{if .Products}}
<div class="compare-table-wrap">
<table class="compare-table">
<thead>
<tr>
<th></th>
{{range .Products}}
<th>
<a href="/product/{{.ID}}">{{.Name}}</a>
<form method="POST" action="/compare/remove" class="compare-table__remove">
<input type="hidden" name="product_id" value="{{.ID}}">
<button type="submit" class="btn btn--ghost btn--sm">Убрать</button>
</form>
</th>
{{end}}
</tr>
</thead>
<tbody>
<tr>
<td>Фото</td>
{{range .Products}}
<td><img src="{{.ImageURL}}" alt="" class="compare-table__img"></td>
{{end}}
</tr>
<tr>
<td>Цена</td>
{{range .Products}}
<td>
{{if .HasSale}}
<span class="price-block__old">{{printf "%.0f" .Price}} ₽</span>
<strong>{{printf "%.0f" .SalePrice}} ₽</strong>
{{else}}
<strong>{{printf "%.0f" .Price}} ₽</strong>
{{end}}
</td>
{{end}}
</tr>
<tr>
<td>Категория</td>
{{range .Products}}<td>{{.Category}}</td>{{end}}
</tr>
<tr class="compare-table__spec">
<td>Бренд</td>
{{range .Products}}<td>{{if .AttrBrand}}{{.AttrBrand}}{{else}}—{{end}}</td>{{end}}
</tr>
<tr class="compare-table__spec">
<td>Размер</td>
{{range .Products}}<td>{{if .AttrSize}}{{.AttrSize}}{{else}}—{{end}}</td>{{end}}
</tr>
<tr class="compare-table__spec">
<td>Цвет</td>
{{range .Products}}<td>{{if .AttrColor}}{{.AttrColor}}{{else}}—{{end}}</td>{{end}}
</tr>
<tr class="compare-table__spec">
<td>Материал</td>
{{range .Products}}<td>{{if .AttrMaterial}}{{.AttrMaterial}}{{else}}—{{end}}</td>{{end}}
</tr>
<tr class="compare-table__spec">
<td>Вес</td>
{{range .Products}}<td>{{if .AttrWeight}}{{.AttrWeight}}{{else}}—{{end}}</td>{{end}}
</tr>
<tr>
<td></td>
{{range .Products}}
<td>
<form method="POST" action="/buy">
<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>
</td>
{{end}}
</tr>
</tbody>
</table>
</div>
{{else}}
<p class="shop-hint">Добавьте до 2 товаров для сравнения со страницы товара.</p>
<a href="/" class="btn btn--primary">В каталог</a>
{{end}}
</main>
</body>
</html>
{{end}}
{{define "category.html"}}
+3 -20
View File
@@ -43,7 +43,7 @@
<h2 class="section__title">Популярные категории</h2> <h2 class="section__title">Популярные категории</h2>
<div class="categories"> <div class="categories">
{{range .Categories}} {{range .Categories}}
<a href="#catalog" class="category-card"> <a href="/category/{{.Slug}}" class="category-card">
<span class="category-card__name">{{.Name}}</span> <span class="category-card__name">{{.Name}}</span>
<span class="category-card__count">{{.Count}} товаров</span> <span class="category-card__count">{{.Count}} товаров</span>
</a> </a>
@@ -60,24 +60,7 @@
</div> </div>
<div class="products"> <div class="products">
{{range .Products}} {{range .Products}}
<article class="product-card"> {{template "product_card" .}}
<div class="product-card__image">
<img src="{{.ImageURL}}" alt="{{.Name}}" loading="lazy">
<span class="product-card__badge">{{.Category}}</span>
</div>
<div class="product-card__body">
<h3 class="product-card__title">{{.Name}}</h3>
<p class="product-card__desc">{{plain .Description}}</p>
<div class="product-card__footer">
<span class="product-card__price">{{printf "%.0f" .Price}} ₽</span>
<form method="POST" action="/cart/add">
<input type="hidden" name="product_id" value="{{.ID}}">
<input type="hidden" name="quantity" value="1">
<button type="submit" class="btn btn--primary btn--sm">В корзину</button>
</form>
</div>
</div>
</article>
{{else}} {{else}}
<p class="empty">Товары скоро появятся в каталоге.</p> <p class="empty">Товары скоро появятся в каталоге.</p>
{{end}} {{end}}
@@ -118,7 +101,7 @@
<div class="footer__brand"> <div class="footer__brand">
<span class="logo__icon"></span> <span class="logo__icon"></span>
<span class="logo__text">Shop</span> <span class="logo__text">Shop</span>
<p class="footer__copy">© 2026 Shop. Все права защищены.</p> <p class="footer__copy">© 2026 Shop · v{{.Version}}</p>
</div> </div>
<div class="footer__links"> <div class="footer__links">
<a href="/account">Личный кабинет</a> <a href="/account">Личный кабинет</a>
@@ -0,0 +1,30 @@
{{define "product_card"}}
<article class="product-card">
<a href="/product/{{.ID}}" class="product-card__link">
<div class="product-card__image">
<img src="{{.ImageURL}}" alt="{{.Name}}" loading="lazy">
{{if .HasSale}}<span class="product-card__sale">-{{.DiscountPercent}}%</span>{{end}}
<span class="product-card__badge">{{.Category}}</span>
</div>
<div class="product-card__body">
<h3 class="product-card__title">{{.Name}}</h3>
<p class="product-card__desc">{{plain .Description}}</p>
<div class="product-card__footer">
<div class="price-block">
{{if .HasSale}}
<span class="price-block__old">{{printf "%.0f" .Price}} ₽</span>
<span class="product-card__price price-block__sale">{{printf "%.0f" .SalePrice}} ₽</span>
{{else}}
<span class="product-card__price">{{printf "%.0f" .Price}} ₽</span>
{{end}}
</div>
</div>
</div>
</a>
<form method="POST" action="/cart/add" class="product-card__cart">
<input type="hidden" name="product_id" value="{{.ID}}">
<input type="hidden" name="quantity" value="1">
<button type="submit" class="btn btn--primary btn--sm">В корзину</button>
</form>
</article>
{{end}}
+1 -1
View File
@@ -8,7 +8,7 @@
<nav class="nav"> <nav class="nav">
<a href="/#catalog" class="nav__link">Каталог</a> <a href="/#catalog" class="nav__link">Каталог</a>
<a href="/#categories" class="nav__link">Категории</a> <a href="/#categories" class="nav__link">Категории</a>
<a href="/#delivery" class="nav__link">Доставка</a> <a href="/compare" class="nav__link">Сравнение{{if .CompareCount}} ({{.CompareCount}}){{end}}</a>
</nav> </nav>
<div class="header__actions"> <div class="header__actions">
{{if .User}} {{if .User}}
+72 -5
View File
@@ -19,8 +19,15 @@
<div class="cart-item"> <div class="cart-item">
<img src="{{.Product.ImageURL}}" alt="{{.Product.Name}}" class="cart-item__img"> <img src="{{.Product.ImageURL}}" alt="{{.Product.Name}}" class="cart-item__img">
<div class="cart-item__info"> <div class="cart-item__info">
<h3>{{.Product.Name}}</h3> <h3><a href="/product/{{.ProductID}}">{{.Product.Name}}</a></h3>
<p class="cart-item__price">{{printf "%.0f" .Product.Price}} ₽</p> <div class="price-block">
{{if .Product.HasSale}}
<span class="price-block__old">{{printf "%.0f" .Product.Price}} ₽</span>
<span class="price-block__sale">{{printf "%.0f" .Product.SalePrice}} ₽</span>
{{else}}
<span class="cart-item__price">{{printf "%.0f" .Product.Price}} ₽</span>
{{end}}
</div>
</div> </div>
<form method="POST" action="/cart/update" class="cart-item__qty"> <form method="POST" action="/cart/update" class="cart-item__qty">
<input type="hidden" name="product_id" value="{{.ProductID}}"> <input type="hidden" name="product_id" value="{{.ProductID}}">
@@ -79,6 +86,27 @@
<label>Email<input type="email" name="email" value="{{.Email}}" required></label> <label>Email<input type="email" name="email" value="{{.Email}}" required></label>
<label>Телефон<input type="tel" name="phone" value="{{.Phone}}" required></label> <label>Телефон<input type="tel" name="phone" value="{{.Phone}}" required></label>
<label>Адрес доставки<textarea name="address" rows="3" required>{{.Address}}</textarea></label> <label>Адрес доставки<textarea name="address" rows="3" required>{{.Address}}</textarea></label>
<h3 class="checkout-payment-title">Способ оплаты</h3>
<div class="checkout-payment">
<label class="checkout-payment__option">
<input type="radio" name="payment_method" value="cod" checked>
<span class="checkout-payment__label">
<strong>Оплата при получении</strong>
<small>Наличными или картой курьеру</small>
</span>
</label>
{{if .HeleketEnabled}}
<label class="checkout-payment__option">
<input type="radio" name="payment_method" value="heleket">
<span class="checkout-payment__label">
<strong>Криптовалюта</strong>
<small>Оплата через Heleket — BTC, USDT и др.</small>
</span>
</label>
{{end}}
</div>
<button type="submit" class="btn btn--primary btn--lg">Подтвердить заказ</button> <button type="submit" class="btn btn--primary btn--lg">Подтвердить заказ</button>
</form> </form>
<aside class="cart-summary"> <aside class="cart-summary">
@@ -111,9 +139,25 @@
{{template "shop_header" .}} {{template "shop_header" .}}
<main class="shop-page container"> <main class="shop-page container">
<div class="shop-card shop-card--success"> <div class="shop-card shop-card--success">
{{if .Order.IsHeleket}}
{{if eq .Order.Status "paid"}}
<h1>✓ Оплата получена!</h1>
<p>Заказ #{{.Order.ID}} оплачен. Мы свяжемся с вами для доставки.</p>
{{else}}
<h1>Заказ #{{.Order.ID}} создан</h1>
<p>Ожидаем оплату криптовалютой через Heleket.</p>
{{if .Order.HeleketPaymentStatus}}
<p class="shop-hint">Статус платежа: <strong>{{.Order.HeleketPaymentStatus}}</strong></p>
{{end}}
{{if .Order.HeleketPaymentURL}}
<a href="/order/{{.Order.ID}}/pay" class="btn btn--primary btn--lg">Перейти к оплате</a>
{{end}}
{{end}}
{{else}}
<h1>✓ Заказ оформлен!</h1> <h1>✓ Заказ оформлен!</h1>
<p>{{.Success}}</p> <p>{{.Success}}</p>
<p class="shop-hint">Мы свяжемся с вами для подтверждения доставки.</p> <p class="shop-hint">Мы свяжемся с вами для подтверждения доставки.</p>
{{end}}
<div class="shop-actions"> <div class="shop-actions">
<a href="/" class="btn btn--primary">На главную</a> <a href="/" class="btn btn--primary">На главную</a>
{{if .User}}<a href="/account" class="btn btn--outline">Мои заказы</a>{{end}} {{if .User}}<a href="/account" class="btn btn--outline">Мои заказы</a>{{end}}
@@ -164,16 +208,39 @@
{{range .Orders}} {{range .Orders}}
<div class="order-card"> <div class="order-card">
<div class="order-card__head"> <div class="order-card__head">
<strong>Заказ #{{.ID}}</strong> <div>
<span>{{.CreatedAt.Format "02.01.2006 15:04"}}</span> <strong>Заказ #{{.ID}}</strong>
<span class="admin-badge admin-badge--ok">{{.Status}}</span> <span class="order-card__date">{{.CreatedAt.Format "02.01.2006 15:04"}}</span>
</div>
<span class="order-status {{orderClass .Status}}"><span class="order-status__icon">{{orderIcon .Status}}</span> {{orderLabel .Status}}</span>
</div> </div>
{{if not (orderTerminal .Status)}}
<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"><span class="order-status__icon">{{orderIcon .}}</span> {{orderLabel .}}</span>
</div>
{{end}}
</div>
{{end}}
<ul class="order-card__items"> <ul class="order-card__items">
{{range .Items}} {{range .Items}}
<li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li> <li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li>
{{end}} {{end}}
</ul> </ul>
<p class="order-card__total">Итого: {{printf "%.0f" .Total}} ₽</p> <p class="order-card__total">Итого: {{printf "%.0f" .Total}} ₽</p>
{{if .IsHeleket}}
<p class="order-card__payment">
Оплата: Heleket
{{if .HeleketPaymentStatus}}({{.HeleketPaymentStatus}}){{end}}
{{if and .HeleketPaymentURL (ne .Status "paid")}}
<a href="/order/{{.ID}}/pay">оплатить</a>
{{end}}
</p>
{{end}}
</div> </div>
{{end}} {{end}}
{{else}} {{else}}
+138
View File
@@ -0,0 +1,138 @@
package heleket
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
const apiBase = "https://api.heleket.com"
type Client struct {
merchantID string
apiKey string
http *http.Client
}
type InvoiceRequest struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
OrderID string `json:"order_id"`
URLReturn string `json:"url_return,omitempty"`
URLSuccess string `json:"url_success,omitempty"`
URLCallback string `json:"url_callback,omitempty"`
PayerEmail string `json:"payer_email,omitempty"`
Lifetime int `json:"lifetime,omitempty"`
AdditionalData string `json:"additional_data,omitempty"`
}
type Invoice struct {
UUID string `json:"uuid"`
OrderID string `json:"order_id"`
Amount string `json:"amount"`
PaymentStatus string `json:"payment_status"`
URL string `json:"url"`
ExpiredAt int64 `json:"expired_at"`
IsFinal bool `json:"is_final"`
}
type apiResponse struct {
State int `json:"state"`
Result json.RawMessage `json:"result"`
Errors json.RawMessage `json:"errors"`
}
func NewClient(merchantID, apiKey string) *Client {
return &Client{
merchantID: merchantID,
apiKey: apiKey,
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) Enabled() bool {
return c.merchantID != "" && c.apiKey != ""
}
func OrderExternalID(orderID int) string {
return fmt.Sprintf("shop-order-%d", orderID)
}
func ParseOrderExternalID(orderID string) (int, bool) {
const prefix = "shop-order-"
if len(orderID) <= len(prefix) || orderID[:len(prefix)] != prefix {
return 0, false
}
id, err := strconv.Atoi(orderID[len(prefix):])
return id, err == nil && id > 0
}
func FormatAmount(amount float64) string {
return strconv.FormatFloat(amount, 'f', 2, 64)
}
func (c *Client) CreateInvoice(ctx context.Context, req InvoiceRequest) (Invoice, error) {
body, err := encodeBody(req)
if err != nil {
return Invoice{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/v1/payment", bytes.NewReader(body))
if err != nil {
return Invoice{}, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("merchant", c.merchantID)
httpReq.Header.Set("sign", SignBody(body, c.apiKey))
resp, err := c.http.Do(httpReq)
if err != nil {
return Invoice{}, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return Invoice{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Invoice{}, fmt.Errorf("heleket http %d: %s", resp.StatusCode, string(respBody))
}
var wrapped apiResponse
if err := json.Unmarshal(respBody, &wrapped); err != nil {
return Invoice{}, err
}
if wrapped.State != 0 {
return Invoice{}, fmt.Errorf("heleket api state %d: %s", wrapped.State, string(wrapped.Errors))
}
var invoice Invoice
if err := json.Unmarshal(wrapped.Result, &invoice); err != nil {
return Invoice{}, err
}
return invoice, nil
}
// MapPaymentStatus maps Heleket payment_status to shop order status.
func MapPaymentStatus(paymentStatus string) string {
switch paymentStatus {
case "paid", "paid_over":
return "paid"
case "cancel", "fail", "system_fail":
return "cancelled"
case "refund_paid":
return "refunded"
default:
return "unpaid"
}
}
func IsPaid(paymentStatus string) bool {
return paymentStatus == "paid" || paymentStatus == "paid_over"
}
+55
View File
@@ -0,0 +1,55 @@
package heleket
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"encoding/json"
"strings"
)
// encodeBody matches PHP json_encode default (slashes escaped, unicode unescaped).
func encodeBody(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
return []byte(strings.ReplaceAll(string(b), "/", `\/`)), nil
}
func SignBody(body []byte, apiKey string) string {
escaped := strings.ReplaceAll(string(body), "/", `\/`)
sum := md5.Sum([]byte(base64.StdEncoding.EncodeToString([]byte(escaped)) + apiKey))
return hex.EncodeToString(sum[:])
}
func VerifyWebhook(body []byte, apiKey string) (map[string]any, bool) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return nil, false
}
signRaw, ok := raw["sign"]
if !ok {
return nil, false
}
var sign string
if err := json.Unmarshal(signRaw, &sign); err != nil {
return nil, false
}
delete(raw, "sign")
payload, err := encodeBody(raw)
if err != nil {
return nil, false
}
if SignBody(payload, apiKey) != sign {
return nil, false
}
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
return nil, false
}
delete(data, "sign")
return data, true
}
+43 -14
View File
@@ -3,17 +3,43 @@ package models
import "time" import "time"
type Product struct { type Product struct {
ID int ID int
Name string Name string
Description string Description string
Details string Details string
Price float64 Price float64
ImageURL string SalePrice float64
Category string ImageURL string
IsActive bool Category string
CreatedAt time.Time CategoryID *int
UpdatedAt time.Time CategorySlug string
Images []ProductImage IsActive bool
AttrSize string
AttrColor string
AttrMaterial string
AttrWeight string
AttrBrand string
CreatedAt time.Time
UpdatedAt time.Time
Images []ProductImage
}
func (p Product) EffectivePrice() float64 {
if p.SalePrice > 0 && p.SalePrice < p.Price {
return p.SalePrice
}
return p.Price
}
func (p Product) HasSale() bool {
return p.SalePrice > 0 && p.SalePrice < p.Price
}
func (p Product) DiscountPercent() int {
if !p.HasSale() || p.Price <= 0 {
return 0
}
return int((1 - p.SalePrice/p.Price) * 100)
} }
type ProductImage struct { type ProductImage struct {
@@ -26,9 +52,12 @@ type ProductImage struct {
} }
type Category struct { type Category struct {
Name string ID int
Slug string Name string
Count int Slug string
Description string
SortOrder int
Count int
} }
type SiteStats struct { type SiteStats struct {
+47
View File
@@ -0,0 +1,47 @@
package models
import "time"
type ProductSpec struct {
Label string
Value string
}
func (p Product) Specs() []ProductSpec {
var specs []ProductSpec
if p.AttrBrand != "" {
specs = append(specs, ProductSpec{"Бренд", p.AttrBrand})
}
if p.AttrSize != "" {
specs = append(specs, ProductSpec{"Размер", p.AttrSize})
}
if p.AttrColor != "" {
specs = append(specs, ProductSpec{"Цвет", p.AttrColor})
}
if p.AttrMaterial != "" {
specs = append(specs, ProductSpec{"Материал", p.AttrMaterial})
}
if p.AttrWeight != "" {
specs = append(specs, ProductSpec{"Вес", p.AttrWeight})
}
return specs
}
type PriceHistoryEntry struct {
ID int
ProductID int
Price float64
SalePrice float64
RecordedAt time.Time
}
type Reservation struct {
ID int
ProductID int
SessionKey string
UserID *int
CustomerName string
CustomerPhone string
ExpiresAt time.Time
CreatedAt time.Time
}
+19 -10
View File
@@ -28,16 +28,25 @@ type CartItem struct {
} }
type Order struct { type Order struct {
ID int ID int
UserID *int UserID *int
GuestName string GuestName string
GuestEmail string GuestEmail string
GuestPhone string GuestPhone string
Address string Address string
Total float64 Total float64
Status string Status string
CreatedAt time.Time PaymentMethod string
Items []OrderItem HeleketUUID string
HeleketOrderID string
HeleketPaymentURL string
HeleketPaymentStatus string
CreatedAt time.Time
Items []OrderItem
}
func (o Order) IsHeleket() bool {
return o.PaymentMethod == "heleket"
} }
type OrderItem struct { type OrderItem struct {
+60
View File
@@ -0,0 +1,60 @@
package orderstatus
type Info struct {
Icon string
Label string
Class string
Step int
Terminal bool
}
var statuses = map[string]Info{
"pending": {Icon: "⏳", Label: "В ожидании", Class: "status--pending", Step: 1},
"unpaid": {Icon: "💳", Label: "Не оплачено", Class: "status--unpaid", Step: 1},
"paid": {Icon: "✓", Label: "Оплачено", Class: "status--paid", Step: 2},
"processing": {Icon: "⚙", Label: "В обработке", Class: "status--processing", Step: 3},
"shipped": {Icon: "📦", Label: "Отправлен", Class: "status--shipped", Step: 4},
"delivered": {Icon: "✓", Label: "Доставлен", Class: "status--delivered", Step: 5},
"cancelled": {Icon: "✕", Label: "Отменён", Class: "status--cancelled", Step: 0, Terminal: true},
"refunded": {Icon: "↩", Label: "Возврат", Class: "status--refunded", Step: 0, Terminal: true},
// совместимость со старыми заказами
"new": {Icon: "⏳", Label: "В ожидании", Class: "status--pending", Step: 1},
"confirmed": {Icon: "✓", Label: "Оплачено", Class: "status--paid", Step: 2},
}
// Timeline — основной путь доставки (для таймлайна в кабинете)
var Timeline = []string{"pending", "paid", "processing", "shipped", "delivered"}
// AllCodes — все статусы для выбора в админке
var AllCodes = []string{
"pending", "unpaid", "paid", "processing", "shipped", "delivered", "cancelled", "refunded",
}
func Normalize(code string) string {
if code == "new" {
return "pending"
}
if code == "confirmed" {
return "paid"
}
return code
}
func Get(code string) Info {
code = Normalize(code)
if s, ok := statuses[code]; ok {
return s
}
return Info{Label: code, Class: "status--pending", Step: 1}
}
func Label(code string) string { return Get(code).Label }
func Icon(code string) string { return Get(code).Icon }
func Class(code string) string { return Get(code).Class }
func Step(code string) int { return Get(code).Step }
func IsTerminal(code string) bool { return Get(code).Terminal }
func Valid(code string) bool {
_, ok := statuses[Normalize(code)]
return ok
}
+15 -3
View File
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"database/sql"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -138,9 +139,12 @@ func (r *CartRepository) ItemCount(ctx context.Context, cartID int) (int, error)
func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartItem, error) { func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartItem, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT ci.id, ci.cart_id, ci.product_id, ci.quantity, SELECT ci.id, ci.cart_id, ci.product_id, ci.quantity,
p.id, p.name, p.description, p.details, p.price, p.image_url, p.category, p.is_active, p.created_at, p.updated_at p.id, p.name, p.description, p.details, p.price, COALESCE(p.sale_price, 0),
p.image_url, COALESCE(c.name, p.category, 'Разное'), p.category_id,
COALESCE(c.slug, ''), p.is_active, p.created_at, p.updated_at
FROM cart_items ci FROM cart_items ci
JOIN products p ON p.id = ci.product_id JOIN products p ON p.id = ci.product_id
LEFT JOIN categories c ON c.id = p.category_id
WHERE ci.cart_id = $1 AND p.is_active = true WHERE ci.cart_id = $1 AND p.is_active = true
ORDER BY ci.id ORDER BY ci.id
`, cartID) `, cartID)
@@ -153,12 +157,18 @@ func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartIt
for rows.Next() { for rows.Next() {
var ci models.CartItem var ci models.CartItem
var p models.Product var p models.Product
var catID sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
&ci.ID, &ci.CartID, &ci.ProductID, &ci.Quantity, &ci.ID, &ci.CartID, &ci.ProductID, &ci.Quantity,
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt, &p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
&p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if catID.Valid {
id := int(catID.Int64)
p.CategoryID = &id
}
ci.Product = p ci.Product = p
items = append(items, ci) items = append(items, ci)
} }
@@ -168,7 +178,9 @@ func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartIt
func (r *CartRepository) Total(ctx context.Context, cartID int) (float64, error) { func (r *CartRepository) Total(ctx context.Context, cartID int) (float64, error) {
var total float64 var total float64
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
SELECT COALESCE(SUM(ci.quantity * p.price), 0) SELECT COALESCE(SUM(ci.quantity * CASE
WHEN p.sale_price > 0 AND p.sale_price < p.price THEN p.sale_price
ELSE p.price END), 0)
FROM cart_items ci JOIN products p ON p.id = ci.product_id FROM cart_items ci JOIN products p ON p.id = ci.product_id
WHERE ci.cart_id = $1 WHERE ci.cart_id = $1
`, cartID).Scan(&total) `, cartID).Scan(&total)
+125
View File
@@ -0,0 +1,125 @@
package repository
import (
"context"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type CategoryRepository struct {
pool *pgxpool.Pool
}
func NewCategoryRepository(pool *pgxpool.Pool) *CategoryRepository {
return &CategoryRepository{pool: pool}
}
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
func Slugify(name string) string {
translit := map[rune]string{
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e",
'ж': "zh", 'з': "z", 'и': "i", 'й': "y", 'к': "k", 'л': "l", 'м': "m",
'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t", 'у': "u",
'ф': "f", 'х': "h", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "sch",
'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya",
}
var b strings.Builder
for _, r := range strings.ToLower(name) {
if t, ok := translit[r]; ok {
b.WriteString(t)
} else if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else if r == ' ' || r == '-' {
b.WriteRune('-')
}
}
s := slugRe.ReplaceAllString(b.String(), "-")
return strings.Trim(s, "-")
}
func (r *CategoryRepository) All(ctx context.Context) ([]models.Category, error) {
rows, err := r.pool.Query(ctx, `
SELECT c.id, c.name, c.slug, c.description, c.sort_order,
COUNT(p.id) FILTER (WHERE p.is_active = true) AS cnt
FROM categories c
LEFT JOIN products p ON p.category_id = c.id
GROUP BY c.id
ORDER BY c.sort_order, c.name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.Category
for rows.Next() {
var c models.Category
if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder, &c.Count); err != nil {
return nil, err
}
list = append(list, c)
}
return list, rows.Err()
}
func (r *CategoryRepository) GetBySlug(ctx context.Context, slug string) (models.Category, error) {
var c models.Category
err := r.pool.QueryRow(ctx, `
SELECT id, name, slug, description, sort_order FROM categories WHERE slug = $1
`, slug).Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder)
return c, err
}
func (r *CategoryRepository) GetByID(ctx context.Context, id int) (models.Category, error) {
var c models.Category
err := r.pool.QueryRow(ctx, `
SELECT id, name, slug, description, sort_order FROM categories WHERE id = $1
`, id).Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder)
return c, err
}
func (r *CategoryRepository) Create(ctx context.Context, name, description string) (models.Category, error) {
slug := Slugify(name)
var c models.Category
err := r.pool.QueryRow(ctx, `
INSERT INTO categories (name, slug, description)
VALUES ($1, $2, $3)
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id, name, slug, description, sort_order
`, name, slug, description).Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder)
return c, err
}
func (r *CategoryRepository) Update(ctx context.Context, c *models.Category) error {
tag, err := r.pool.Exec(ctx, `
UPDATE categories SET name = $1, slug = $2, description = $3, sort_order = $4 WHERE id = $5
`, c.Name, c.Slug, c.Description, c.SortOrder, c.ID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (r *CategoryRepository) Delete(ctx context.Context, id int) error {
_, err := r.pool.Exec(ctx, `UPDATE products SET category_id = NULL WHERE category_id = $1`, id)
if err != nil {
return err
}
tag, err := r.pool.Exec(ctx, `DELETE FROM categories WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
+114 -10
View File
@@ -3,11 +3,19 @@ package repository
import ( import (
"context" "context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models" "shop/internal/models"
) )
const orderSelect = `
SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status,
COALESCE(payment_method, 'cod'), COALESCE(heleket_uuid, ''), COALESCE(heleket_order_id, ''),
COALESCE(heleket_payment_url, ''), COALESCE(heleket_payment_status, ''), created_at
FROM orders
`
type OrderRepository struct { type OrderRepository struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
@@ -16,6 +24,15 @@ func NewOrderRepository(pool *pgxpool.Pool) *OrderRepository {
return &OrderRepository{pool: pool} return &OrderRepository{pool: pool}
} }
func scanOrder(row pgx.Row) (models.Order, error) {
var o models.Order
err := row.Scan(
&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status,
&o.PaymentMethod, &o.HeleketUUID, &o.HeleketOrderID, &o.HeleketPaymentURL, &o.HeleketPaymentStatus, &o.CreatedAt,
)
return o, err
}
func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items []models.CartItem) error { func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items []models.CartItem) error {
tx, err := r.pool.Begin(ctx) tx, err := r.pool.Begin(ctx)
if err != nil { if err != nil {
@@ -23,11 +40,20 @@ func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
status := order.Status
if status == "" {
status = "pending"
}
paymentMethod := order.PaymentMethod
if paymentMethod == "" {
paymentMethod = "cod"
}
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status) INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status, payment_method)
VALUES ($1, $2, $3, $4, $5, $6, 'new') VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at RETURNING id, created_at
`, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total, `, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total, status, paymentMethod,
).Scan(&order.ID, &order.CreatedAt) ).Scan(&order.ID, &order.CreatedAt)
if err != nil { if err != nil {
return err return err
@@ -37,7 +63,7 @@ func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
INSERT INTO order_items (order_id, product_id, product_name, price, quantity) INSERT INTO order_items (order_id, product_id, product_name, price, quantity)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
`, order.ID, item.ProductID, item.Product.Name, item.Product.Price, item.Quantity) `, order.ID, item.ProductID, item.Product.Name, item.Product.EffectivePrice(), item.Quantity)
if err != nil { if err != nil {
return err return err
} }
@@ -46,11 +72,37 @@ func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items
return tx.Commit(ctx) return tx.Commit(ctx)
} }
func (r *OrderRepository) UpdateHeleketPayment(ctx context.Context, orderID int, uuid, externalID, paymentURL, paymentStatus string) error {
_, err := r.pool.Exec(ctx, `
UPDATE orders SET
heleket_uuid = $1,
heleket_order_id = $2,
heleket_payment_url = $3,
heleket_payment_status = $4
WHERE id = $5
`, uuid, externalID, paymentURL, paymentStatus, orderID)
return err
}
func (r *OrderRepository) UpdateHeleketStatus(ctx context.Context, orderID int, paymentStatus, orderStatus string) error {
_, err := r.pool.Exec(ctx, `
UPDATE orders SET heleket_payment_status = $1, status = $2 WHERE id = $3
`, paymentStatus, orderStatus, orderID)
return err
}
func (r *OrderRepository) GetByHeleketOrderID(ctx context.Context, externalID string) (models.Order, error) {
row := r.pool.QueryRow(ctx, orderSelect+` WHERE heleket_order_id = $1`, externalID)
o, err := scanOrder(row)
if err != nil {
return models.Order{}, err
}
o.Items, err = r.items(ctx, o.ID)
return o, err
}
func (r *OrderRepository) ByUser(ctx context.Context, userID int) ([]models.Order, error) { func (r *OrderRepository) ByUser(ctx context.Context, userID int) ([]models.Order, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, orderSelect+` WHERE user_id = $1 ORDER BY created_at DESC`, userID)
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 { if err != nil {
return nil, err return nil, err
} }
@@ -58,8 +110,8 @@ func (r *OrderRepository) ByUser(ctx context.Context, userID int) ([]models.Orde
var orders []models.Order var orders []models.Order
for rows.Next() { for rows.Next() {
var o models.Order o, err := scanOrder(rows)
if err := rows.Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt); err != nil { if err != nil {
return nil, err return nil, err
} }
o.Items, err = r.items(ctx, o.ID) o.Items, err = r.items(ctx, o.ID)
@@ -91,3 +143,55 @@ func (r *OrderRepository) items(ctx context.Context, orderID int) ([]models.Orde
} }
return items, rows.Err() return items, rows.Err()
} }
func (r *OrderRepository) All(ctx context.Context, limit int) ([]models.Order, error) {
rows, err := r.pool.Query(ctx, orderSelect+` ORDER BY created_at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return r.collectOrders(ctx, rows)
}
func (r *OrderRepository) GetByID(ctx context.Context, id int) (models.Order, error) {
row := r.pool.QueryRow(ctx, orderSelect+` WHERE id = $1`, id)
o, err := scanOrder(row)
if err != nil {
return models.Order{}, err
}
o.Items, err = r.items(ctx, o.ID)
return o, err
}
func (r *OrderRepository) UpdateStatus(ctx context.Context, id int, status string) error {
tag, err := r.pool.Exec(ctx, `UPDATE orders SET status = $1 WHERE id = $2`, status, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (r *OrderRepository) Count(ctx context.Context) (int, error) {
var n int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM orders`).Scan(&n)
return n, err
}
func (r *OrderRepository) collectOrders(ctx context.Context, rows pgx.Rows) ([]models.Order, error) {
var orders []models.Order
for rows.Next() {
o, err := scanOrder(rows)
if 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()
}
+49
View File
@@ -0,0 +1,49 @@
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type PriceHistoryRepository struct {
pool *pgxpool.Pool
}
func NewPriceHistoryRepository(pool *pgxpool.Pool) *PriceHistoryRepository {
return &PriceHistoryRepository{pool: pool}
}
func (r *PriceHistoryRepository) Record(ctx context.Context, productID int, price, salePrice float64) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO product_price_history (product_id, price, sale_price)
VALUES ($1, $2, NULLIF($3, 0))
`, productID, price, salePrice)
return err
}
func (r *PriceHistoryRepository) ByProduct(ctx context.Context, productID int, limit int) ([]models.PriceHistoryEntry, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, product_id, price, COALESCE(sale_price, 0), recorded_at
FROM product_price_history
WHERE product_id = $1
ORDER BY recorded_at DESC
LIMIT $2
`, productID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []models.PriceHistoryEntry
for rows.Next() {
var e models.PriceHistoryEntry
if err := rows.Scan(&e.ID, &e.ProductID, &e.Price, &e.SalePrice, &e.RecordedAt); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
+80 -44
View File
@@ -2,8 +2,8 @@ package repository
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -19,25 +19,37 @@ func NewProductRepository(pool *pgxpool.Pool) *ProductRepository {
return &ProductRepository{pool: pool} return &ProductRepository{pool: pool}
} }
const productSelect = `
SELECT p.id, p.name, p.description, p.details, p.price, COALESCE(p.sale_price, 0),
p.image_url, COALESCE(c.name, p.category, 'Разное'), p.category_id,
COALESCE(c.slug, ''), p.is_active,
COALESCE(p.attr_size, ''), COALESCE(p.attr_color, ''), COALESCE(p.attr_material, ''),
COALESCE(p.attr_weight, ''), COALESCE(p.attr_brand, ''),
p.created_at, p.updated_at
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
`
func (r *ProductRepository) scanProduct(row pgx.Row) (models.Product, error) { func (r *ProductRepository) scanProduct(row pgx.Row) (models.Product, error) {
var p models.Product var p models.Product
var catID sql.NullInt64
err := row.Scan( err := row.Scan(
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
&p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt, &p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive,
&p.AttrSize, &p.AttrColor, &p.AttrMaterial, &p.AttrWeight, &p.AttrBrand,
&p.CreatedAt, &p.UpdatedAt,
) )
if catID.Valid {
id := int(catID.Int64)
p.CategoryID = &id
}
return p, err return p, err
} }
const productColumns = `id, name, description, details, price, image_url, category, is_active, created_at, updated_at`
func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.Product, error) { func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.Product, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, productSelect+`
SELECT `+productColumns+` WHERE p.is_active = true
FROM products ORDER BY p.created_at DESC LIMIT $1`, limit)
WHERE is_active = true
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -46,11 +58,18 @@ func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.P
} }
func (r *ProductRepository) All(ctx context.Context) ([]models.Product, error) { func (r *ProductRepository) All(ctx context.Context) ([]models.Product, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, productSelect+` ORDER BY p.created_at DESC`)
SELECT `+productColumns+` if err != nil {
FROM products return nil, err
ORDER BY created_at DESC }
`) defer rows.Close()
return r.collectProducts(rows)
}
func (r *ProductRepository) ByCategoryID(ctx context.Context, categoryID int) ([]models.Product, error) {
rows, err := r.pool.Query(ctx, productSelect+`
WHERE p.is_active = true AND p.category_id = $1
ORDER BY p.created_at DESC`, categoryID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -59,7 +78,17 @@ func (r *ProductRepository) All(ctx context.Context) ([]models.Product, error) {
} }
func (r *ProductRepository) GetByID(ctx context.Context, id int) (models.Product, error) { func (r *ProductRepository) GetByID(ctx context.Context, id int) (models.Product, error) {
row := r.pool.QueryRow(ctx, `SELECT `+productColumns+` FROM products WHERE id = $1`, id) row := r.pool.QueryRow(ctx, productSelect+` WHERE p.id = $1`, id)
p, err := r.scanProduct(row)
if err != nil {
return models.Product{}, err
}
p.Images, err = r.ImagesByProduct(ctx, id)
return p, err
}
func (r *ProductRepository) GetActiveByID(ctx context.Context, id int) (models.Product, error) {
row := r.pool.QueryRow(ctx, productSelect+` WHERE p.id = $1 AND p.is_active = true`, id)
p, err := r.scanProduct(row) p, err := r.scanProduct(row)
if err != nil { if err != nil {
return models.Product{}, err return models.Product{}, err
@@ -70,20 +99,24 @@ func (r *ProductRepository) GetByID(ctx context.Context, id int) (models.Product
func (r *ProductRepository) Create(ctx context.Context, p *models.Product) error { func (r *ProductRepository) Create(ctx context.Context, p *models.Product) error {
return r.pool.QueryRow(ctx, ` return r.pool.QueryRow(ctx, `
INSERT INTO products (name, description, details, price, image_url, category, is_active) INSERT INTO products (name, description, details, price, sale_price, image_url, category, category_id, is_active,
VALUES ($1, $2, $3, $4, $5, $6, $7) attr_size, attr_color, attr_material, attr_weight, attr_brand)
VALUES ($1, $2, $3, $4, NULLIF($5, 0), $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, created_at, updated_at RETURNING id, created_at, updated_at
`, p.Name, p.Description, p.Details, p.Price, p.ImageURL, p.Category, p.IsActive, `, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive,
p.AttrSize, p.AttrColor, p.AttrMaterial, p.AttrWeight, p.AttrBrand,
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) ).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
} }
func (r *ProductRepository) Update(ctx context.Context, p *models.Product) error { func (r *ProductRepository) Update(ctx context.Context, p *models.Product) error {
tag, err := r.pool.Exec(ctx, ` tag, err := r.pool.Exec(ctx, `
UPDATE products UPDATE products SET name = $1, description = $2, details = $3, price = $4,
SET name = $1, description = $2, details = $3, price = $4, sale_price = NULLIF($5, 0), image_url = $6, category = $7, category_id = $8,
image_url = $5, category = $6, is_active = $7, updated_at = NOW() is_active = $9, attr_size = $10, attr_color = $11, attr_material = $12,
WHERE id = $8 attr_weight = $13, attr_brand = $14, updated_at = NOW()
`, p.Name, p.Description, p.Details, p.Price, p.ImageURL, p.Category, p.IsActive, p.ID) WHERE id = $15
`, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive,
p.AttrSize, p.AttrColor, p.AttrMaterial, p.AttrWeight, p.AttrBrand, p.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -106,10 +139,12 @@ func (r *ProductRepository) Delete(ctx context.Context, id int) error {
func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category, error) { func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT category, COUNT(*) AS cnt SELECT c.id, c.name, c.slug, c.description, c.sort_order,
FROM products COUNT(p.id) FILTER (WHERE p.is_active = true)
GROUP BY category FROM categories c
ORDER BY cnt DESC LEFT JOIN products p ON p.category_id = c.id
GROUP BY c.id
ORDER BY c.sort_order, c.name
`) `)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -119,10 +154,9 @@ func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category,
var categories []models.Category var categories []models.Category
for rows.Next() { for rows.Next() {
var c models.Category var c models.Category
if err := rows.Scan(&c.Name, &c.Count); err != nil { if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder, &c.Count); err != nil {
return nil, err return nil, err
} }
c.Slug = c.Name
categories = append(categories, c) categories = append(categories, c)
} }
return categories, rows.Err() return categories, rows.Err()
@@ -131,8 +165,7 @@ func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category,
func (r *ProductRepository) ImagesByProduct(ctx context.Context, productID int) ([]models.ProductImage, error) { func (r *ProductRepository) ImagesByProduct(ctx context.Context, productID int) ([]models.ProductImage, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT id, product_id, file_path, is_primary, sort_order, created_at SELECT id, product_id, file_path, is_primary, sort_order, created_at
FROM product_images FROM product_images WHERE product_id = $1
WHERE product_id = $1
ORDER BY is_primary DESC, sort_order ASC, id ASC ORDER BY is_primary DESC, sort_order ASC, id ASC
`, productID) `, productID)
if err != nil { if err != nil {
@@ -154,21 +187,17 @@ func (r *ProductRepository) ImagesByProduct(ctx context.Context, productID int)
func (r *ProductRepository) AddImage(ctx context.Context, img *models.ProductImage) error { func (r *ProductRepository) AddImage(ctx context.Context, img *models.ProductImage) error {
return r.pool.QueryRow(ctx, ` return r.pool.QueryRow(ctx, `
INSERT INTO product_images (product_id, file_path, is_primary, sort_order) INSERT INTO product_images (product_id, file_path, is_primary, sort_order)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4) RETURNING id, created_at
RETURNING id, created_at `, img.ProductID, img.FilePath, img.IsPrimary, img.SortOrder).Scan(&img.ID, &img.CreatedAt)
`, img.ProductID, img.FilePath, img.IsPrimary, img.SortOrder,
).Scan(&img.ID, &img.CreatedAt)
} }
func (r *ProductRepository) DeleteImage(ctx context.Context, imageID, productID int) (string, error) { func (r *ProductRepository) DeleteImage(ctx context.Context, imageID, productID int) (string, error) {
var path string var path string
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
DELETE FROM product_images DELETE FROM product_images WHERE id = $1 AND product_id = $2 RETURNING file_path
WHERE id = $1 AND product_id = $2
RETURNING file_path
`, imageID, productID).Scan(&path) `, imageID, productID).Scan(&path)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return "", fmt.Errorf("image not found") return "", errors.New("image not found")
} }
return path, err return path, err
} }
@@ -177,12 +206,19 @@ func (r *ProductRepository) collectProducts(rows pgx.Rows) ([]models.Product, er
var products []models.Product var products []models.Product
for rows.Next() { for rows.Next() {
var p models.Product var p models.Product
var catID sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
&p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt, &p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive,
&p.AttrSize, &p.AttrColor, &p.AttrMaterial, &p.AttrWeight, &p.AttrBrand,
&p.CreatedAt, &p.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if catID.Valid {
id := int(catID.Int64)
p.CategoryID = &id
}
products = append(products, p) products = append(products, p)
} }
return products, rows.Err() return products, rows.Err()
+79
View File
@@ -0,0 +1,79 @@
package repository
import (
"context"
"database/sql"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type ReservationRepository struct {
pool *pgxpool.Pool
}
func NewReservationRepository(pool *pgxpool.Pool) *ReservationRepository {
return &ReservationRepository{pool: pool}
}
func (r *ReservationRepository) Create(ctx context.Context, res *models.Reservation) error {
return r.pool.QueryRow(ctx, `
INSERT INTO product_reservations (product_id, session_key, user_id, customer_name, customer_phone, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at
`, res.ProductID, res.SessionKey, res.UserID, res.CustomerName, res.CustomerPhone, res.ExpiresAt,
).Scan(&res.ID, &res.CreatedAt)
}
func (r *ReservationRepository) ActiveByProduct(ctx context.Context, productID int) (*models.Reservation, error) {
var res models.Reservation
var userID sql.NullInt64
err := r.pool.QueryRow(ctx, `
SELECT id, product_id, session_key, user_id, customer_name, customer_phone, expires_at, created_at
FROM product_reservations
WHERE product_id = $1 AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
`, productID).Scan(
&res.ID, &res.ProductID, &res.SessionKey, &userID,
&res.CustomerName, &res.CustomerPhone, &res.ExpiresAt, &res.CreatedAt,
)
if err != nil {
return nil, err
}
if userID.Valid {
id := int(userID.Int64)
res.UserID = &id
}
return &res, nil
}
func (r *ReservationRepository) ReplaceForProduct(ctx context.Context, res *models.Reservation) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `DELETE FROM product_reservations WHERE product_id = $1`, res.ProductID)
if err != nil {
return err
}
err = tx.QueryRow(ctx, `
INSERT INTO product_reservations (product_id, session_key, user_id, customer_name, customer_phone, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at
`, res.ProductID, res.SessionKey, res.UserID, res.CustomerName, res.CustomerPhone, res.ExpiresAt,
).Scan(&res.ID, &res.CreatedAt)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func (r *ReservationRepository) ExpiresIn() time.Duration {
return 24 * time.Hour
}
+4
View File
@@ -0,0 +1,4 @@
package version
// Version задаётся при сборке: -ldflags "-X shop/internal/version.Version=..."
var Version = "dev"
+24
View File
@@ -0,0 +1,24 @@
-- Категории и цена со скидкой
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INT REFERENCES categories(id) ON DELETE SET NULL;
ALTER TABLE products ADD COLUMN IF NOT EXISTS sale_price NUMERIC(10, 2);
INSERT INTO categories (name, slug, sort_order) VALUES
('Электроника', 'elektronika', 1),
('Одежда', 'odezhda', 2),
('Дом и сад', 'dom-i-sad', 3),
('Аксессуары', 'aksessuary', 4),
('Разное', 'raznoe', 5)
ON CONFLICT (slug) DO NOTHING;
UPDATE products p SET category_id = c.id
FROM categories c WHERE p.category = c.name AND p.category_id IS NULL;
+29
View File
@@ -0,0 +1,29 @@
ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_size VARCHAR(50) NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_color VARCHAR(50) NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_material VARCHAR(100) NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_weight VARCHAR(50) NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_brand VARCHAR(100) NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS product_price_history (
id SERIAL PRIMARY KEY,
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
price NUMERIC(10,2) NOT NULL,
sale_price NUMERIC(10,2),
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_price_history_product ON product_price_history (product_id, recorded_at DESC);
CREATE TABLE IF NOT EXISTS product_reservations (
id SERIAL PRIMARY KEY,
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
session_key VARCHAR(64) NOT NULL DEFAULT '',
user_id INT REFERENCES users(id) ON DELETE SET NULL,
customer_name VARCHAR(255) NOT NULL DEFAULT '',
customer_phone VARCHAR(50) NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_reservations_product ON product_reservations (product_id, expires_at DESC);
CREATE INDEX IF NOT EXISTS idx_reservations_session ON product_reservations (session_key);
+5
View File
@@ -0,0 +1,5 @@
ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cod';
ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_uuid VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_order_id VARCHAR(128) NOT NULL DEFAULT '';
ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_url TEXT NOT NULL DEFAULT '';
ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_status VARCHAR(50) NOT NULL DEFAULT '';
+5 -1
View File
@@ -25,8 +25,12 @@ if [[ ! -f .env ]]; then
echo "Проверьте ADMIN_EMAIL и DOMAIN перед продакшеном." echo "Проверьте ADMIN_EMAIL и DOMAIN перед продакшеном."
fi fi
export APP_VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
docker compose pull --ignore-buildable 2>/dev/null || true docker compose pull --ignore-buildable 2>/dev/null || true
docker compose up --build -d docker compose build app
docker compose up -d --force-recreate
echo "" echo ""
echo "Сервисы запущены." echo "Сервисы запущены."
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Полная пересборка и перезапуск
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
echo "=== Shop redeploy ==="
if [[ -d .git ]]; then
echo "Обновление кода из git (сброс локальных правок скриптов)..."
git fetch origin main
git reset --hard origin/main
export APP_VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)
echo "Git: $(git log -1 --oneline)"
else
export APP_VERSION="manual"
fi
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "Version: ${APP_VERSION}"
chmod +x scripts/*.sh 2>/dev/null || true
echo ""
echo "Останавливаем app..."
docker compose stop app 2>/dev/null || true
echo "Пересборка образа (без кэша)..."
docker compose build --no-cache app
echo "Запуск контейнеров..."
docker compose up -d --force-recreate app caddy
echo ""
echo "Ожидание запуска..."
sleep 3
echo ""
echo "Проверка версии:"
curl -sf "http://127.0.0.1/health" 2>/dev/null || curl -sf "http://localhost/health" || docker compose exec -T app wget -qO- http://127.0.0.1:8080/health
echo ""
echo ""
IP=$(hostname -I | awk '{print $1}')
echo "Готово: http://${IP}"
echo "Логи: docker compose logs -f app"
+14 -2
View File
@@ -4,8 +4,20 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}" cd "${ROOT_DIR}"
git pull origin main git fetch origin main
docker compose up --build -d git reset --hard origin/main
chmod +x scripts/*.sh 2>/dev/null || true
export APP_VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "Обновление до ${APP_VERSION}..."
docker compose build app
docker compose up -d --force-recreate --no-deps app caddy
docker image prune -f docker image prune -f
sleep 2
echo "Версия на сервере:"
curl -sf http://127.0.0.1/health || curl -sf http://localhost/health || true
echo ""
echo "Обновление завершено." echo "Обновление завершено."