Compare commits
6
Commits
v0.10-beta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a0bb01e2 | ||
|
|
361d35e6b4 | ||
|
|
e000264bb1 | ||
|
|
532351e1c9 | ||
|
|
306002bfa1 | ||
|
|
efc95d48c4 |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
+63
-14
@@ -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,8 +35,15 @@ 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)
|
||||||
|
cartRepo := repository.NewCartRepository(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 {
|
||||||
@@ -46,35 +55,77 @@ func main() {
|
|||||||
log.Fatalf("admin seed: %v", err)
|
log.Fatalf("admin seed: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("admin ready: %s", cfg.AdminEmail)
|
log.Printf("admin ready: %s", cfg.AdminEmail)
|
||||||
} else {
|
|
||||||
log.Println("warning: set ADMIN_EMAIL and ADMIN_PASSWORD in .env to create admin")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
home, err := handlers.NewHomeHandler(productRepo, statsRepo)
|
tmpl, err := handlers.ParseTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("handler: %v", err)
|
log.Fatalf("templates: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
session := auth.NewSession(cfg.SessionSecret)
|
adminSession := auth.NewSession(cfg.SessionSecret, "shop_admin_session", "/admin")
|
||||||
admin := handlers.NewAdminHandler(adminRepo, productRepo, statsRepo, storage, session, home.Templates())
|
userSession := auth.NewSession(cfg.SessionSecret, "shop_user_session", "/")
|
||||||
|
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(
|
||||||
|
productRepo, categoryRepo, userRepo, cartRepo, orderRepo,
|
||||||
|
priceHistoryRepo, reservationRepo, statsRepo,
|
||||||
|
userSession, cartSession, compareSession,
|
||||||
|
heleketClient, cfg.HeleketPaymentAPIKey, cfg.PublicBaseURL, cfg.HeleketCurrency,
|
||||||
|
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/", home.Static()))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler()))
|
||||||
mux.Handle("GET /uploads/", storage.FileServer())
|
mux.Handle("GET /uploads/", storage.FileServer())
|
||||||
mux.HandleFunc("GET /health", home.Health)
|
mux.HandleFunc("GET /health", customer.Health)
|
||||||
mux.HandleFunc("GET /{$}", home.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("POST /register", customer.Register)
|
||||||
|
mux.HandleFunc("GET /login", customer.LoginPage)
|
||||||
|
mux.HandleFunc("POST /login", customer.Login)
|
||||||
|
mux.HandleFunc("POST /logout", customer.Logout)
|
||||||
|
mux.HandleFunc("GET /cart", customer.CartPage)
|
||||||
|
mux.HandleFunc("POST /cart/add", customer.CartAdd)
|
||||||
|
mux.HandleFunc("POST /cart/update", customer.CartUpdate)
|
||||||
|
mux.HandleFunc("POST /cart/remove", customer.CartRemove)
|
||||||
|
mux.HandleFunc("GET /checkout", customer.CheckoutPage)
|
||||||
|
mux.HandleFunc("POST /checkout", customer.Checkout)
|
||||||
|
mux.HandleFunc("GET /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("POST /account", customer.Account)
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin/login", admin.LoginPage)
|
mux.HandleFunc("GET /admin/login", admin.LoginPage)
|
||||||
mux.HandleFunc("POST /admin/login", admin.Login)
|
mux.HandleFunc("POST /admin/login", admin.Login)
|
||||||
mux.HandleFunc("POST /admin/logout", admin.Logout)
|
mux.HandleFunc("POST /admin/logout", admin.Logout)
|
||||||
mux.HandleFunc("GET /admin/{$}", admin.Dashboard)
|
mux.HandleFunc("GET /admin/{$}", admin.Dashboard)
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin/products", admin.ProductList)
|
mux.HandleFunc("GET /admin/products", admin.ProductList)
|
||||||
mux.HandleFunc("GET /admin/products/new", admin.ProductNew)
|
mux.HandleFunc("GET /admin/products/new", admin.ProductNew)
|
||||||
mux.HandleFunc("POST /admin/products/save", admin.ProductSave)
|
mux.HandleFunc("POST /admin/products/save", admin.ProductSave)
|
||||||
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{
|
||||||
@@ -86,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)
|
||||||
}
|
}
|
||||||
@@ -98,9 +149,7 @@ func main() {
|
|||||||
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
_ = srv.Shutdown(shutdownCtx)
|
||||||
log.Printf("shutdown: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ services:
|
|||||||
- ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
|
- ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
|
||||||
- ./migrations/002_admins.sql:/docker-entrypoint-initdb.d/002_admins.sql:ro
|
- ./migrations/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/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
|
||||||
@@ -25,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
|
||||||
@@ -33,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:
|
||||||
|
|||||||
+67
-35
@@ -9,27 +9,33 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const defaultTTL = 7 * 24 * time.Hour
|
||||||
cookieName = "shop_session"
|
|
||||||
sessionTTL = 24 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
secret []byte
|
secret []byte
|
||||||
|
cookieName string
|
||||||
|
path string
|
||||||
|
ttl time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSession(secret string) *Session {
|
func NewSession(secret, cookieName, path string) *Session {
|
||||||
return &Session{secret: []byte(secret)}
|
return &Session{
|
||||||
|
secret: []byte(secret),
|
||||||
|
cookieName: cookieName,
|
||||||
|
path: path,
|
||||||
|
ttl: defaultTTL,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) Create(email string) (string, error) {
|
func (s *Session) Create(value string) (string, error) {
|
||||||
exp := time.Now().Add(sessionTTL).Unix()
|
exp := time.Now().Add(s.ttl).Unix()
|
||||||
payload := fmt.Sprintf("%s|%d", email, exp)
|
payload := fmt.Sprintf("%s|%d", value, exp)
|
||||||
sig := s.sign(payload)
|
sig := s.sign(payload)
|
||||||
token := base64.URLEncoding.EncodeToString([]byte(payload + "|" + sig))
|
return base64.URLEncoding.EncodeToString([]byte(payload + "|" + sig)), nil
|
||||||
return token, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) Validate(token string) (string, bool) {
|
func (s *Session) Validate(token string) (string, bool) {
|
||||||
@@ -37,67 +43,93 @@ func (s *Session) Validate(token string) (string, bool) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(string(raw), "|")
|
parts := strings.Split(string(raw), "|")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
value, expStr, sig := parts[0], parts[1], parts[2]
|
||||||
email, expStr, sig := parts[0], parts[1], parts[2]
|
payload := value + "|" + expStr
|
||||||
payload := email + "|" + expStr
|
|
||||||
|
|
||||||
if !hmac.Equal([]byte(sig), []byte(s.sign(payload))) {
|
if !hmac.Equal([]byte(sig), []byte(s.sign(payload))) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||||
if err != nil || time.Now().Unix() > exp {
|
if err != nil || time.Now().Unix() > exp {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
return value, true
|
||||||
return email, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) SetCookie(w http.ResponseWriter, r *http.Request, token string) {
|
func (s *Session) SetCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: s.cookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
Path: "/admin",
|
Path: s.path,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: s.isSecure(r),
|
Secure: isSecure(r),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: int(sessionTTL.Seconds()),
|
MaxAge: int(s.ttl.Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) ClearCookie(w http.ResponseWriter, r *http.Request) {
|
func (s *Session) ClearCookie(w http.ResponseWriter, r *http.Request) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: s.cookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/admin",
|
Path: s.path,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: s.isSecure(r),
|
Secure: isSecure(r),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) isSecure(r *http.Request) bool {
|
|
||||||
if r.TLS != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Session) FromRequest(r *http.Request) (string, bool) {
|
func (s *Session) FromRequest(r *http.Request) (string, bool) {
|
||||||
c, err := r.Cookie(cookieName)
|
c, err := r.Cookie(s.cookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return s.Validate(c.Value)
|
return s.Validate(c.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Session) EnsureKey(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
if key, ok := s.FromRequest(r); ok && key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
key := uuid.New().String()
|
||||||
|
token, _ := s.Create(key)
|
||||||
|
s.SetCookie(w, r, token)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Session) sign(payload string) string {
|
func (s *Session) sign(payload string) string {
|
||||||
mac := hmac.New(sha256.New, s.secret)
|
mac := hmac.New(sha256.New, s.secret)
|
||||||
mac.Write([]byte(payload))
|
mac.Write([]byte(payload))
|
||||||
return base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
return base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isSecure(r *http.Request) bool {
|
||||||
|
if r.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserIDFromSession(s *Session, r *http.Request) (int, bool) {
|
||||||
|
val, ok := s.FromRequest(r)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(val, "user:") {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(strings.TrimPrefix(val, "user:"))
|
||||||
|
return id, err == nil && id > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetUserSession(s *Session, w http.ResponseWriter, r *http.Request, userID int) error {
|
||||||
|
token, err := s.Create(fmt.Sprintf("user:%d", userID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.SetCookie(w, r, token)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -33,6 +33,99 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
|||||||
visit_date DATE NOT NULL DEFAULT CURRENT_DATE
|
visit_date DATE NOT NULL DEFAULT CURRENT_DATE
|
||||||
)`,
|
)`,
|
||||||
`INSERT INTO site_stats (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
|
`INSERT INTO site_stats (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS carts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
session_key VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_user ON carts (user_id) WHERE user_id IS NOT NULL`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_session ON carts (session_key) WHERE user_id IS NULL`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS cart_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
cart_id INT NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
|
||||||
|
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
UNIQUE (cart_id, product_id)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
guest_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
guest_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
guest_phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
total NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'new',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS order_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
product_name VARCHAR(255) NOT NULL,
|
||||||
|
price NUMERIC(10,2) NOT NULL,
|
||||||
|
quantity INT NOT NULL CHECK (quantity > 0)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INT REFERENCES categories(id) ON DELETE SET NULL`,
|
||||||
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS sale_price NUMERIC(10,2)`,
|
||||||
|
`INSERT INTO categories (name, slug, sort_order) VALUES
|
||||||
|
('Электроника', 'elektronika', 1),
|
||||||
|
('Одежда', 'odezhda', 2),
|
||||||
|
('Дом и сад', 'dom-i-sad', 3),
|
||||||
|
('Аксессуары', 'aksessuary', 4),
|
||||||
|
('Разное', 'raznoe', 5)
|
||||||
|
ON CONFLICT (slug) DO NOTHING`,
|
||||||
|
`UPDATE products p SET category_id = c.id
|
||||||
|
FROM categories c WHERE p.category = c.name AND p.category_id IS NULL`,
|
||||||
|
`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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,560 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"shop/internal/auth"
|
||||||
|
"shop/internal/compare"
|
||||||
|
"shop/internal/heleket"
|
||||||
|
"shop/internal/models"
|
||||||
|
"shop/internal/repository"
|
||||||
|
"shop/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomerHandler struct {
|
||||||
|
products *repository.ProductRepository
|
||||||
|
categories *repository.CategoryRepository
|
||||||
|
users *repository.UserRepository
|
||||||
|
cart *repository.CartRepository
|
||||||
|
orders *repository.OrderRepository
|
||||||
|
priceHistory *repository.PriceHistoryRepository
|
||||||
|
reservations *repository.ReservationRepository
|
||||||
|
stats *repository.StatsRepository
|
||||||
|
userSession *auth.Session
|
||||||
|
cartSession *auth.Session
|
||||||
|
compareSession *auth.Session
|
||||||
|
heleket *heleket.Client
|
||||||
|
heleketAPIKey string
|
||||||
|
publicBaseURL string
|
||||||
|
heleketCurrency string
|
||||||
|
templates *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
type shopPage struct {
|
||||||
|
Title string
|
||||||
|
User *models.User
|
||||||
|
CartCount int
|
||||||
|
CompareCount int
|
||||||
|
Version string
|
||||||
|
Products interface{}
|
||||||
|
Categories interface{}
|
||||||
|
Error string
|
||||||
|
Success string
|
||||||
|
}
|
||||||
|
|
||||||
|
type cartPage struct {
|
||||||
|
shopPage
|
||||||
|
Items []models.CartItem
|
||||||
|
Total float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkoutPage struct {
|
||||||
|
shopPage
|
||||||
|
Items []models.CartItem
|
||||||
|
Total float64
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Phone string
|
||||||
|
Address string
|
||||||
|
HeleketEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountPage struct {
|
||||||
|
shopPage
|
||||||
|
Orders []models.Order
|
||||||
|
Name string
|
||||||
|
Phone 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(
|
||||||
|
products *repository.ProductRepository,
|
||||||
|
categories *repository.CategoryRepository,
|
||||||
|
users *repository.UserRepository,
|
||||||
|
cart *repository.CartRepository,
|
||||||
|
orders *repository.OrderRepository,
|
||||||
|
priceHistory *repository.PriceHistoryRepository,
|
||||||
|
reservations *repository.ReservationRepository,
|
||||||
|
stats *repository.StatsRepository,
|
||||||
|
userSession, cartSession, compareSession *auth.Session,
|
||||||
|
heleketClient *heleket.Client,
|
||||||
|
heleketAPIKey, publicBaseURL, heleketCurrency string,
|
||||||
|
templates *template.Template,
|
||||||
|
) *CustomerHandler {
|
||||||
|
return &CustomerHandler{
|
||||||
|
products: products, categories: categories, users: users, cart: cart, orders: orders,
|
||||||
|
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) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
if err := h.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
log.Printf("render %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) basePage(r *http.Request, w http.ResponseWriter, title string) shopPage {
|
||||||
|
p := shopPage{Title: title, Version: version.Version}
|
||||||
|
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||||
|
if u, err := h.users.GetByID(r.Context(), uid); err == nil {
|
||||||
|
p.User = &u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cartID, _ := h.resolveCart(r, w)
|
||||||
|
if cartID > 0 {
|
||||||
|
if n, err := h.cart.ItemCount(r.Context(), cartID); err == nil {
|
||||||
|
p.CartCount = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.CompareCount = h.compareCount(r)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) resolveCart(r *http.Request, w http.ResponseWriter) (int, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
sessionKey := h.cartSession.EnsureKey(w, r)
|
||||||
|
var userID *int
|
||||||
|
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||||
|
userID = &uid
|
||||||
|
}
|
||||||
|
return h.cart.GetOrCreate(ctx, sessionKey, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
_ = h.stats.IncrementHomeVisit(ctx)
|
||||||
|
|
||||||
|
products, _ := h.products.Featured(ctx, 8)
|
||||||
|
categories, _ := h.products.Categories(ctx)
|
||||||
|
|
||||||
|
p := h.basePage(r, w, "Shop — Интернет-магазин")
|
||||||
|
p.Products = products
|
||||||
|
p.Categories = categories
|
||||||
|
h.render(w, "home.html", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) ProductPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
if _, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(w, "register.html", h.basePage(r, w, "Регистрация"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
name := r.FormValue("name")
|
||||||
|
|
||||||
|
p := h.basePage(r, w, "Регистрация")
|
||||||
|
if email == "" || password == "" || name == "" {
|
||||||
|
p.Error = "Заполните все поля"
|
||||||
|
h.render(w, "register.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(password) < 6 {
|
||||||
|
p.Error = "Пароль минимум 6 символов"
|
||||||
|
h.render(w, "register.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exists, _ := h.users.EmailExists(ctx, email)
|
||||||
|
if exists {
|
||||||
|
p.Error = "Email уже зарегистрирован"
|
||||||
|
h.render(w, "register.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.users.Create(ctx, email, password, name)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("register: %v", err)
|
||||||
|
p.Error = "Ошибка регистрации"
|
||||||
|
h.render(w, "register.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := h.cartSession.EnsureKey(w, r)
|
||||||
|
_ = h.cart.MergeToUser(ctx, sessionKey, u.ID)
|
||||||
|
_ = auth.SetUserSession(h.userSession, w, r, u.ID)
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(w, "login.html", h.basePage(r, w, "Вход"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
u, err := h.users.Authenticate(ctx, r.FormValue("email"), r.FormValue("password"))
|
||||||
|
p := h.basePage(r, w, "Вход")
|
||||||
|
if err != nil {
|
||||||
|
p.Error = "Неверный email или пароль"
|
||||||
|
h.render(w, "login.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionKey := h.cartSession.EnsureKey(w, r)
|
||||||
|
_ = h.cart.MergeToUser(ctx, sessionKey, u.ID)
|
||||||
|
_ = auth.SetUserSession(h.userSession, w, r, u.ID)
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.userSession.ClearCookie(w, r)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) CartAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productID, _ := strconv.Atoi(r.FormValue("product_id"))
|
||||||
|
qty, _ := strconv.Atoi(r.FormValue("quantity"))
|
||||||
|
if qty < 1 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
if productID <= 0 {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cartID, err := h.resolveCart(r, w)
|
||||||
|
if err == nil {
|
||||||
|
_ = h.cart.AddItem(r.Context(), cartID, productID, qty)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/cart", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) CartPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cartID, err := h.resolveCart(r, w)
|
||||||
|
p := cartPage{shopPage: h.basePage(r, w, "Корзина")}
|
||||||
|
if err != nil || cartID == 0 {
|
||||||
|
h.render(w, "cart.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
p.Items, _ = h.cart.Items(ctx, cartID)
|
||||||
|
p.Total, _ = h.cart.Total(ctx, cartID)
|
||||||
|
h.render(w, "cart.html", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) CartUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productID, _ := strconv.Atoi(r.FormValue("product_id"))
|
||||||
|
qty, _ := strconv.Atoi(r.FormValue("quantity"))
|
||||||
|
cartID, _ := h.resolveCart(r, w)
|
||||||
|
if cartID > 0 {
|
||||||
|
_ = h.cart.UpdateItem(r.Context(), cartID, productID, qty)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/cart", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) CartRemove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productID, _ := strconv.Atoi(r.FormValue("product_id"))
|
||||||
|
cartID, _ := h.resolveCart(r, w)
|
||||||
|
if cartID > 0 {
|
||||||
|
_ = h.cart.RemoveItem(r.Context(), cartID, productID)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/cart", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) CheckoutPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cartID, _ := h.resolveCart(r, w)
|
||||||
|
ctx := r.Context()
|
||||||
|
items, _ := h.cart.Items(ctx, cartID)
|
||||||
|
if len(items) == 0 {
|
||||||
|
http.Redirect(w, r, "/cart", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, _ := h.cart.Total(ctx, cartID)
|
||||||
|
p := checkoutPage{
|
||||||
|
shopPage: h.basePage(r, w, "Оформление заказа"),
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
HeleketEnabled: h.heleket.Enabled(),
|
||||||
|
}
|
||||||
|
if p.User != nil {
|
||||||
|
p.Name = p.User.Name
|
||||||
|
p.Email = p.User.Email
|
||||||
|
p.Phone = p.User.Phone
|
||||||
|
p.Address = p.User.Address
|
||||||
|
}
|
||||||
|
h.render(w, "checkout.html", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
cartID, _ := h.resolveCart(r, w)
|
||||||
|
items, _ := h.cart.Items(ctx, cartID)
|
||||||
|
if len(items) == 0 {
|
||||||
|
http.Redirect(w, r, "/cart", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, _ := h.cart.Total(ctx, cartID)
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
email := r.FormValue("email")
|
||||||
|
phone := r.FormValue("phone")
|
||||||
|
address := r.FormValue("address")
|
||||||
|
|
||||||
|
p := checkoutPage{
|
||||||
|
shopPage: h.basePage(r, w, "Оформление заказа"),
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
Name: name,
|
||||||
|
Email: email,
|
||||||
|
Phone: phone,
|
||||||
|
Address: address,
|
||||||
|
HeleketEnabled: h.heleket.Enabled(),
|
||||||
|
}
|
||||||
|
if name == "" || email == "" || phone == "" || address == "" {
|
||||||
|
p.Error = "Заполните все поля доставки"
|
||||||
|
h.render(w, "checkout.html", p)
|
||||||
|
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{
|
||||||
|
GuestName: name, GuestEmail: email, GuestPhone: phone,
|
||||||
|
Address: address, Total: total, PaymentMethod: paymentMethod,
|
||||||
|
}
|
||||||
|
if paymentMethod == "heleket" {
|
||||||
|
order.Status = "unpaid"
|
||||||
|
}
|
||||||
|
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||||
|
order.UserID = &uid
|
||||||
|
_ = h.users.UpdateProfile(ctx, &models.User{ID: uid, Name: name, Phone: phone, Address: address})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.orders.Create(ctx, &order, items); err != nil {
|
||||||
|
log.Printf("checkout: %v", err)
|
||||||
|
p.Error = "Ошибка оформления заказа"
|
||||||
|
h.render(w, "checkout.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
success := h.basePage(r, w, "Заказ оформлен")
|
||||||
|
success.Success = "Заказ #" + strconv.Itoa(order.ID) + " успешно создан!"
|
||||||
|
h.render(w, "order_success.html", success)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Account(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, ok := auth.UserIDFromSession(h.userSession, r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
u, err := h.users.GetByID(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
u.Name = r.FormValue("name")
|
||||||
|
u.Phone = r.FormValue("phone")
|
||||||
|
u.Address = r.FormValue("address")
|
||||||
|
_ = h.users.UpdateProfile(ctx, &u)
|
||||||
|
}
|
||||||
|
|
||||||
|
orders, _ := h.orders.ByUser(ctx, uid)
|
||||||
|
p := accountPage{
|
||||||
|
shopPage: h.basePage(r, w, "Личный кабинет"),
|
||||||
|
Orders: orders,
|
||||||
|
Name: u.Name,
|
||||||
|
Phone: u.Phone,
|
||||||
|
Address: u.Address,
|
||||||
|
}
|
||||||
|
p.User = &u
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
p.Success = "Профиль сохранён"
|
||||||
|
}
|
||||||
|
h.render(w, "account.html", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
fmt.Fprintf(w, `{"status":"ok","version":%q}`, version.Version)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
+39
-70
@@ -8,7 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"shop/internal/repository"
|
"shop/internal/models"
|
||||||
|
"shop/internal/orderstatus"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*
|
//go:embed templates/*
|
||||||
@@ -17,86 +18,54 @@ var templateFS embed.FS
|
|||||||
//go:embed static/*
|
//go:embed static/*
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
type HomeHandler struct {
|
|
||||||
repo *repository.ProductRepository
|
|
||||||
stats *repository.StatsRepository
|
|
||||||
templates *template.Template
|
|
||||||
}
|
|
||||||
|
|
||||||
type homePageData struct {
|
|
||||||
Title string
|
|
||||||
Products interface{}
|
|
||||||
Categories interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHomeHandler(repo *repository.ProductRepository, stats *repository.StatsRepository) (*HomeHandler, error) {
|
|
||||||
funcMap := template.FuncMap{
|
|
||||||
"plain": stripHTML,
|
|
||||||
}
|
|
||||||
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &HomeHandler{repo: repo, stats: stats, templates: tmpl}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
||||||
|
|
||||||
func stripHTML(s string) string {
|
func stripHTML(s string) string {
|
||||||
return tagRe.ReplaceAllString(s, "")
|
return tagRe.ReplaceAllString(s, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HomeHandler) Templates() *template.Template {
|
func effectivePrice(p models.Product) float64 { return p.EffectivePrice() }
|
||||||
return h.templates
|
|
||||||
|
func ParseTemplates() (*template.Template, error) {
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"plain": stripHTML,
|
||||||
|
"effective": effectivePrice,
|
||||||
|
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
||||||
|
"orderLabel": orderstatus.Label,
|
||||||
|
"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")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HomeHandler) Static() http.Handler {
|
func StaticHandler() http.Handler {
|
||||||
sub, err := fs.Sub(staticFS, "static")
|
sub, err := fs.Sub(staticFS, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
return http.FileServer(http.FS(sub))
|
return http.FileServer(http.FS(sub))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HomeHandler) Home(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
if err := h.stats.IncrementHomeVisit(ctx); err != nil {
|
|
||||||
log.Printf("visit counter: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
products, err := h.repo.Featured(ctx, 8)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("featured products: %v", err)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
categories, err := h.repo.Categories(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("categories: %v", err)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := homePageData{
|
|
||||||
Title: "Shop — Интернет-магазин",
|
|
||||||
Products: products,
|
|
||||||
Categories: categories,
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := h.templates.ExecuteTemplate(w, "home.html", data); err != nil {
|
|
||||||
log.Printf("render: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HomeHandler) Health(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"status":"ok"}`))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,730 @@
|
|||||||
|
.shop-page {
|
||||||
|
padding: 40px 24px 80px;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-card h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-card--empty,
|
||||||
|
.shop-card--success {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-card--success h1 {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-form input,
|
||||||
|
.shop-form textarea {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-form input:focus,
|
||||||
|
.shop-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-alert--error {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-alert--ok {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-hint a {
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-layout,
|
||||||
|
.checkout-layout,
|
||||||
|
.account-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-layout {
|
||||||
|
grid-template-columns: 1fr 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-layout {
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-layout {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-layout .shop-card {
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__img {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__info h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__price {
|
||||||
|
color: #4f46e5;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__qty {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__qty input {
|
||||||
|
width: 60px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-summary {
|
||||||
|
background: #fff;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
height: fit-content;
|
||||||
|
position: sticky;
|
||||||
|
top: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-summary h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-summary__total {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 16px 0;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__head {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__items {
|
||||||
|
list-style: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__total {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card .admin-badge,
|
||||||
|
.order-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__footer form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Цены со скидкой */
|
||||||
|
.price-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-block__old {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-block__sale,
|
||||||
|
.price-block__current {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-block--lg .price-block__sale,
|
||||||
|
.price-block--lg .price-block__current {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-block__badge {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Страница товара */
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a {
|
||||||
|
color: #4f46e5;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 40px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__main {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__thumbs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__thumbs img {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__cat {
|
||||||
|
color: #4f46e5;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__info h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 12px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__short {
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 20px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__buy {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-input {
|
||||||
|
width: 72px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__html {
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__html h1,
|
||||||
|
.product-detail__html h2,
|
||||||
|
.product-detail__html h3 {
|
||||||
|
margin: 1em 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Статусы заказов */
|
||||||
|
.order-card__head {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__date {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-status {
|
||||||
|
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) {
|
||||||
|
.product-detail {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-layout,
|
||||||
|
.checkout-layout,
|
||||||
|
.account-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{{define "register.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<div class="shop-card">
|
||||||
|
<h1>Регистрация</h1>
|
||||||
|
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
|
||||||
|
<form method="POST" action="/register" class="shop-form">
|
||||||
|
<label>Имя<input type="text" name="name" required></label>
|
||||||
|
<label>Email<input type="email" name="email" required></label>
|
||||||
|
<label>Пароль (мин. 6 символов)<input type="password" name="password" required minlength="6"></label>
|
||||||
|
<button type="submit" class="btn btn--primary btn--lg">Зарегистрироваться</button>
|
||||||
|
</form>
|
||||||
|
<p class="shop-hint">Уже есть аккаунт? <a href="/login">Войти</a></p>
|
||||||
|
<p class="shop-hint">Можно покупать и <a href="/cart">без регистрации</a></p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "login.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<div class="shop-card">
|
||||||
|
<h1>Вход</h1>
|
||||||
|
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
|
||||||
|
<form method="POST" action="/login" class="shop-form">
|
||||||
|
<label>Email<input type="email" name="email" required></label>
|
||||||
|
<label>Пароль<input type="password" name="password" required></label>
|
||||||
|
<button type="submit" class="btn btn--primary btn--lg">Войти</button>
|
||||||
|
</form>
|
||||||
|
<p class="shop-hint">Нет аккаунта? <a href="/register">Регистрация</a></p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -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"}}
|
||||||
@@ -11,38 +11,17 @@
|
|||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="header">
|
{{template "shop_header" .}}
|
||||||
<div class="container header__inner">
|
|
||||||
<a href="/" class="logo">
|
|
||||||
<span class="logo__icon">◆</span>
|
|
||||||
<span class="logo__text">Shop</span>
|
|
||||||
</a>
|
|
||||||
<nav class="nav">
|
|
||||||
<a href="#catalog" class="nav__link">Каталог</a>
|
|
||||||
<a href="#categories" class="nav__link">Категории</a>
|
|
||||||
<a href="#delivery" class="nav__link">Доставка</a>
|
|
||||||
</nav>
|
|
||||||
<div class="header__actions">
|
|
||||||
<button class="btn btn--ghost" aria-label="Поиск">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn--primary">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>
|
|
||||||
Корзина
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container hero__inner">
|
<div class="container hero__inner">
|
||||||
<div class="hero__content">
|
<div class="hero__content">
|
||||||
<span class="badge">Новая коллекция 2026</span>
|
<span class="badge">Новая коллекция 2026</span>
|
||||||
<h1 class="hero__title">Всё лучшее —<br>в одном магазине</h1>
|
<h1 class="hero__title">Всё лучшее —<br>в одном магазине</h1>
|
||||||
<p class="hero__text">Электроника, одежда, дом и сад. Быстрая доставка по всей стране, гарантия качества и удобная оплата.</p>
|
<p class="hero__text">Электроника, одежда, дом и сад. Покупайте с регистрацией или без — как вам удобно.</p>
|
||||||
<div class="hero__cta">
|
<div class="hero__cta">
|
||||||
<a href="#catalog" class="btn btn--primary btn--lg">Смотреть каталог</a>
|
<a href="#catalog" class="btn btn--primary btn--lg">Смотреть каталог</a>
|
||||||
<a href="#delivery" class="btn btn--outline btn--lg">Условия доставки</a>
|
<a href="/register" class="btn btn--outline btn--lg">Создать аккаунт</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero__visual">
|
<div class="hero__visual">
|
||||||
@@ -51,8 +30,8 @@
|
|||||||
<span class="hero__card-value">от 3 000 ₽</span>
|
<span class="hero__card-value">от 3 000 ₽</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero__card hero__card--2">
|
<div class="hero__card hero__card--2">
|
||||||
<span class="hero__card-label">Товаров в каталоге</span>
|
<span class="hero__card-label">Покупка без регистрации</span>
|
||||||
<span class="hero__card-value">10 000+</span>
|
<span class="hero__card-value">Доступна</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,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>
|
||||||
@@ -78,24 +57,10 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section__header">
|
<div class="section__header">
|
||||||
<h2 class="section__title">Популярные товары</h2>
|
<h2 class="section__title">Популярные товары</h2>
|
||||||
<a href="#" class="link">Весь каталог →</a>
|
|
||||||
</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>
|
|
||||||
<button class="btn btn--primary btn--sm">В корзину</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="empty">Товары скоро появятся в каталоге.</p>
|
<p class="empty">Товары скоро появятся в каталоге.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -110,22 +75,22 @@
|
|||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature__icon">🚚</div>
|
<div class="feature__icon">🚚</div>
|
||||||
<h3 class="feature__title">Быстрая доставка</h3>
|
<h3 class="feature__title">Быстрая доставка</h3>
|
||||||
<p class="feature__text">Доставим заказ за 1–3 дня в любой город России</p>
|
<p class="feature__text">Доставим заказ за 1–3 дня</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<div class="feature__icon">👤</div>
|
||||||
|
<h3 class="feature__title">Без регистрации</h3>
|
||||||
|
<p class="feature__text">Оформите заказ как гость — достаточно email и телефона</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature__icon">🔒</div>
|
<div class="feature__icon">🔒</div>
|
||||||
<h3 class="feature__title">Безопасная оплата</h3>
|
<h3 class="feature__title">Личный кабинет</h3>
|
||||||
<p class="feature__text">Карты, СБП и оплата при получении</p>
|
<p class="feature__text">История заказов и сохранённые данные для постоянных клиентов</p>
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature__icon">↩️</div>
|
|
||||||
<h3 class="feature__title">Лёгкий возврат</h3>
|
|
||||||
<p class="feature__text">14 дней на возврат без лишних вопросов</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature__icon">💬</div>
|
<div class="feature__icon">💬</div>
|
||||||
<h3 class="feature__title">Поддержка 24/7</h3>
|
<h3 class="feature__title">Поддержка 24/7</h3>
|
||||||
<p class="feature__text">Ответим на любой вопрос в чате или по телефону</p>
|
<p class="feature__text">Ответим на любой вопрос</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,12 +101,12 @@
|
|||||||
<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="#">О компании</a>
|
<a href="/account">Личный кабинет</a>
|
||||||
<a href="#">Контакты</a>
|
<a href="/cart">Корзина</a>
|
||||||
<a href="#">Политика конфиденциальности</a>
|
<a href="/login">Вход</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{define "product_card"}}
|
||||||
|
<article class="product-card">
|
||||||
|
<a href="/product/{{.ID}}" class="product-card__link">
|
||||||
|
<div class="product-card__image">
|
||||||
|
<img src="{{.ImageURL}}" alt="{{.Name}}" loading="lazy">
|
||||||
|
{{if .HasSale}}<span class="product-card__sale">-{{.DiscountPercent}}%</span>{{end}}
|
||||||
|
<span class="product-card__badge">{{.Category}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-card__body">
|
||||||
|
<h3 class="product-card__title">{{.Name}}</h3>
|
||||||
|
<p class="product-card__desc">{{plain .Description}}</p>
|
||||||
|
<div class="product-card__footer">
|
||||||
|
<div class="price-block">
|
||||||
|
{{if .HasSale}}
|
||||||
|
<span class="price-block__old">{{printf "%.0f" .Price}} ₽</span>
|
||||||
|
<span class="product-card__price price-block__sale">{{printf "%.0f" .SalePrice}} ₽</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="product-card__price">{{printf "%.0f" .Price}} ₽</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="/cart/add" class="product-card__cart">
|
||||||
|
<input type="hidden" name="product_id" value="{{.ID}}">
|
||||||
|
<input type="hidden" name="quantity" value="1">
|
||||||
|
<button type="submit" class="btn btn--primary btn--sm">В корзину</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{{define "shop_header"}}
|
||||||
|
<header class="header">
|
||||||
|
<div class="container header__inner">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
<span class="logo__icon">◆</span>
|
||||||
|
<span class="logo__text">Shop</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/#catalog" class="nav__link">Каталог</a>
|
||||||
|
<a href="/#categories" class="nav__link">Категории</a>
|
||||||
|
<a href="/compare" class="nav__link">Сравнение{{if .CompareCount}} ({{.CompareCount}}){{end}}</a>
|
||||||
|
</nav>
|
||||||
|
<div class="header__actions">
|
||||||
|
{{if .User}}
|
||||||
|
<a href="/account" class="btn btn--ghost btn--sm">👤 {{.User.Name}}</a>
|
||||||
|
{{else}}
|
||||||
|
<a href="/login" class="btn btn--ghost btn--sm">Войти</a>
|
||||||
|
<a href="/register" class="btn btn--ghost btn--sm">Регистрация</a>
|
||||||
|
{{end}}
|
||||||
|
<a href="/cart" class="btn btn--primary btn--sm">
|
||||||
|
🛒 Корзина{{if .CartCount}} ({{.CartCount}}){{end}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
{{define "cart.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<h1>Корзина</h1>
|
||||||
|
{{if .Items}}
|
||||||
|
<div class="cart-layout">
|
||||||
|
<div class="cart-items">
|
||||||
|
{{range .Items}}
|
||||||
|
<div class="cart-item">
|
||||||
|
<img src="{{.Product.ImageURL}}" alt="{{.Product.Name}}" class="cart-item__img">
|
||||||
|
<div class="cart-item__info">
|
||||||
|
<h3><a href="/product/{{.ProductID}}">{{.Product.Name}}</a></h3>
|
||||||
|
<div class="price-block">
|
||||||
|
{{if .Product.HasSale}}
|
||||||
|
<span class="price-block__old">{{printf "%.0f" .Product.Price}} ₽</span>
|
||||||
|
<span class="price-block__sale">{{printf "%.0f" .Product.SalePrice}} ₽</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="cart-item__price">{{printf "%.0f" .Product.Price}} ₽</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/cart/update" class="cart-item__qty">
|
||||||
|
<input type="hidden" name="product_id" value="{{.ProductID}}">
|
||||||
|
<input type="number" name="quantity" value="{{.Quantity}}" min="1" max="99">
|
||||||
|
<button type="submit" class="btn btn--ghost btn--sm">OK</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/cart/remove">
|
||||||
|
<input type="hidden" name="product_id" value="{{.ProductID}}">
|
||||||
|
<button type="submit" class="btn btn--danger btn--sm">×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<aside class="cart-summary">
|
||||||
|
<h2>Итого</h2>
|
||||||
|
<p class="cart-summary__total">{{printf "%.0f" .Total}} ₽</p>
|
||||||
|
<a href="/checkout" class="btn btn--primary btn--lg">Оформить заказ</a>
|
||||||
|
<p class="shop-hint">Регистрация не обязательна</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="shop-card shop-card--empty">
|
||||||
|
<p>Корзина пуста</p>
|
||||||
|
<a href="/" class="btn btn--primary">В каталог</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "checkout.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<h1>Оформление заказа</h1>
|
||||||
|
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
|
||||||
|
<div class="checkout-layout">
|
||||||
|
<form method="POST" action="/checkout" class="shop-card shop-form">
|
||||||
|
<h2>Данные для доставки</h2>
|
||||||
|
{{if .User}}
|
||||||
|
<p class="shop-hint">Вы вошли как {{.User.Email}}. Данные подставлены из профиля.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="shop-hint">Покупка без регистрации — укажите контакты для доставки.</p>
|
||||||
|
{{end}}
|
||||||
|
<label>Имя<input type="text" name="name" value="{{.Name}}" required></label>
|
||||||
|
<label>Email<input type="email" name="email" value="{{.Email}}" required></label>
|
||||||
|
<label>Телефон<input type="tel" name="phone" value="{{.Phone}}" required></label>
|
||||||
|
<label>Адрес доставки<textarea name="address" rows="3" required>{{.Address}}</textarea></label>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
<aside class="cart-summary">
|
||||||
|
<h2>Ваш заказ</h2>
|
||||||
|
{{range .Items}}
|
||||||
|
<div class="checkout-item">
|
||||||
|
<span>{{.Product.Name}} × {{.Quantity}}</span>
|
||||||
|
<span>{{printf "%.0f" .Product.Price}} ₽</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<p class="cart-summary__total">Итого: {{printf "%.0f" .Total}} ₽</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "order_success.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<div class="shop-card shop-card--success">
|
||||||
|
{{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>
|
||||||
|
<p>{{.Success}}</p>
|
||||||
|
<p class="shop-hint">Мы свяжемся с вами для подтверждения доставки.</p>
|
||||||
|
{{end}}
|
||||||
|
<div class="shop-actions">
|
||||||
|
<a href="/" class="btn btn--primary">На главную</a>
|
||||||
|
{{if .User}}<a href="/account" class="btn btn--outline">Мои заказы</a>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "account.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<div class="account-header">
|
||||||
|
<h1>Личный кабинет</h1>
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<button type="submit" class="btn btn--ghost">Выйти</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Success}}<div class="shop-alert shop-alert--ok">{{.Success}}</div>{{end}}
|
||||||
|
|
||||||
|
<div class="account-layout">
|
||||||
|
<section class="shop-card">
|
||||||
|
<h2>Профиль</h2>
|
||||||
|
<p class="shop-hint">Email: {{.User.Email}}</p>
|
||||||
|
<form method="POST" action="/account" class="shop-form">
|
||||||
|
<label>Имя<input type="text" name="name" value="{{.Name}}" required></label>
|
||||||
|
<label>Телефон<input type="tel" name="phone" value="{{.Phone}}"></label>
|
||||||
|
<label>Адрес<textarea name="address" rows="2">{{.Address}}</textarea></label>
|
||||||
|
<button type="submit" class="btn btn--primary">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="shop-card">
|
||||||
|
<h2>Мои заказы</h2>
|
||||||
|
{{if .Orders}}
|
||||||
|
{{range .Orders}}
|
||||||
|
<div class="order-card">
|
||||||
|
<div class="order-card__head">
|
||||||
|
<div>
|
||||||
|
<strong>Заказ #{{.ID}}</strong>
|
||||||
|
<span class="order-card__date">{{.CreatedAt.Format "02.01.2006 15:04"}}</span>
|
||||||
|
</div>
|
||||||
|
<span class="order-status {{orderClass .Status}}"><span class="order-status__icon">{{orderIcon .Status}}</span> {{orderLabel .Status}}</span>
|
||||||
|
</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">
|
||||||
|
{{range .Items}}
|
||||||
|
<li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<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>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="shop-hint">Заказов пока нет. <a href="/">Перейти в каталог</a></p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Phone string
|
||||||
|
Address string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cart struct {
|
||||||
|
ID int
|
||||||
|
UserID *int
|
||||||
|
SessionKey string
|
||||||
|
Items []CartItem
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartItem struct {
|
||||||
|
ID int
|
||||||
|
CartID int
|
||||||
|
ProductID int
|
||||||
|
Quantity int
|
||||||
|
Product Product
|
||||||
|
}
|
||||||
|
|
||||||
|
type Order struct {
|
||||||
|
ID int
|
||||||
|
UserID *int
|
||||||
|
GuestName string
|
||||||
|
GuestEmail string
|
||||||
|
GuestPhone string
|
||||||
|
Address string
|
||||||
|
Total float64
|
||||||
|
Status string
|
||||||
|
PaymentMethod string
|
||||||
|
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 {
|
||||||
|
ID int
|
||||||
|
OrderID int
|
||||||
|
ProductID int
|
||||||
|
ProductName string
|
||||||
|
Price float64
|
||||||
|
Quantity int
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"shop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CartRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCartRepository(pool *pgxpool.Pool) *CartRepository {
|
||||||
|
return &CartRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) GetOrCreate(ctx context.Context, sessionKey string, userID *int) (int, error) {
|
||||||
|
if userID != nil {
|
||||||
|
var cartID int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT id FROM carts WHERE user_id = $1`, *userID).Scan(&cartID)
|
||||||
|
if err == nil {
|
||||||
|
return cartID, nil
|
||||||
|
}
|
||||||
|
if err != pgx.ErrNoRows {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
err = r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO carts (user_id, session_key) VALUES ($1, $2) RETURNING id
|
||||||
|
`, *userID, sessionKey).Scan(&cartID)
|
||||||
|
return cartID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cartID int
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id FROM carts WHERE session_key = $1 AND user_id IS NULL
|
||||||
|
`, sessionKey).Scan(&cartID)
|
||||||
|
if err == nil {
|
||||||
|
return cartID, nil
|
||||||
|
}
|
||||||
|
if err != pgx.ErrNoRows {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
err = r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO carts (session_key) VALUES ($1) RETURNING id
|
||||||
|
`, sessionKey).Scan(&cartID)
|
||||||
|
return cartID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) MergeToUser(ctx context.Context, sessionKey string, userID int) error {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var guestCartID int
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
SELECT id FROM carts WHERE session_key = $1 AND user_id IS NULL
|
||||||
|
`, sessionKey).Scan(&guestCartID)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var userCartID int
|
||||||
|
err = tx.QueryRow(ctx, `SELECT id FROM carts WHERE user_id = $1`, userID).Scan(&userCartID)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
_, err = tx.Exec(ctx, `UPDATE carts SET user_id = $1 WHERE id = $2`, userID, guestCartID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO cart_items (cart_id, product_id, quantity)
|
||||||
|
SELECT $1, product_id, quantity FROM cart_items WHERE cart_id = $2
|
||||||
|
ON CONFLICT (cart_id, product_id) DO UPDATE
|
||||||
|
SET quantity = cart_items.quantity + EXCLUDED.quantity
|
||||||
|
`, userCartID, guestCartID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `DELETE FROM carts WHERE id = $1`, guestCartID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) AddItem(ctx context.Context, cartID, productID, qty int) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO cart_items (cart_id, product_id, quantity)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (cart_id, product_id) DO UPDATE
|
||||||
|
SET quantity = cart_items.quantity + EXCLUDED.quantity
|
||||||
|
`, cartID, productID, qty)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) UpdateItem(ctx context.Context, cartID, productID, qty int) error {
|
||||||
|
if qty <= 0 {
|
||||||
|
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2`, cartID, productID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE cart_items SET quantity = $3 WHERE cart_id = $1 AND product_id = $2
|
||||||
|
`, cartID, productID, qty)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) RemoveItem(ctx context.Context, cartID, productID int) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2`, cartID, productID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) Clear(ctx context.Context, cartID int) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1`, cartID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) ItemCount(ctx context.Context, cartID int) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(SUM(quantity), 0) FROM cart_items WHERE cart_id = $1
|
||||||
|
`, cartID).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartItem, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT ci.id, ci.cart_id, ci.product_id, ci.quantity,
|
||||||
|
p.id, p.name, p.description, p.details, p.price, COALESCE(p.sale_price, 0),
|
||||||
|
p.image_url, COALESCE(c.name, p.category, 'Разное'), p.category_id,
|
||||||
|
COALESCE(c.slug, ''), p.is_active, p.created_at, p.updated_at
|
||||||
|
FROM cart_items ci
|
||||||
|
JOIN products p ON p.id = ci.product_id
|
||||||
|
LEFT JOIN categories c ON c.id = p.category_id
|
||||||
|
WHERE ci.cart_id = $1 AND p.is_active = true
|
||||||
|
ORDER BY ci.id
|
||||||
|
`, cartID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []models.CartItem
|
||||||
|
for rows.Next() {
|
||||||
|
var ci models.CartItem
|
||||||
|
var p models.Product
|
||||||
|
var catID sql.NullInt64
|
||||||
|
if err := rows.Scan(
|
||||||
|
&ci.ID, &ci.CartID, &ci.ProductID, &ci.Quantity,
|
||||||
|
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
|
||||||
|
&p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if catID.Valid {
|
||||||
|
id := int(catID.Int64)
|
||||||
|
p.CategoryID = &id
|
||||||
|
}
|
||||||
|
ci.Product = p
|
||||||
|
items = append(items, ci)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) Total(ctx context.Context, cartID int) (float64, error) {
|
||||||
|
var total float64
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(SUM(ci.quantity * CASE
|
||||||
|
WHEN p.sale_price > 0 AND p.sale_price < p.price THEN p.sale_price
|
||||||
|
ELSE p.price END), 0)
|
||||||
|
FROM cart_items ci JOIN products p ON p.id = ci.product_id
|
||||||
|
WHERE ci.cart_id = $1
|
||||||
|
`, cartID).Scan(&total)
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"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 {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrderRepository(pool *pgxpool.Pool) *OrderRepository {
|
||||||
|
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 {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
status := order.Status
|
||||||
|
if status == "" {
|
||||||
|
status = "pending"
|
||||||
|
}
|
||||||
|
paymentMethod := order.PaymentMethod
|
||||||
|
if paymentMethod == "" {
|
||||||
|
paymentMethod = "cod"
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status, payment_method)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id, created_at
|
||||||
|
`, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total, status, paymentMethod,
|
||||||
|
).Scan(&order.ID, &order.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO order_items (order_id, product_id, product_name, price, quantity)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`, order.ID, item.ProductID, item.Product.Name, item.Product.EffectivePrice(), item.Quantity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrderRepository) 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) {
|
||||||
|
rows, err := r.pool.Query(ctx, orderSelect+` WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var orders []models.Order
|
||||||
|
for rows.Next() {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrderRepository) items(ctx context.Context, orderID int) ([]models.OrderItem, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, order_id, product_id, product_name, price, quantity
|
||||||
|
FROM order_items WHERE order_id = $1
|
||||||
|
`, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []models.OrderItem
|
||||||
|
for rows.Next() {
|
||||||
|
var i models.OrderItem
|
||||||
|
if err := rows.Scan(&i.ID, &i.OrderID, &i.ProductID, &i.ProductName, &i.Price, &i.Quantity); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"shop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
|
||||||
|
return &UserRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Create(ctx context.Context, email, password, name string) (models.User, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return models.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var u models.User
|
||||||
|
err = r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO users (email, password_hash, name)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, email, name, phone, address, created_at
|
||||||
|
`, email, string(hash), name).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Authenticate(ctx context.Context, email, password string) (models.User, error) {
|
||||||
|
var hash string
|
||||||
|
var u models.User
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, phone, address, created_at, password_hash
|
||||||
|
FROM users WHERE email = $1
|
||||||
|
`, email).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt, &hash)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return models.User{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return models.User{}, err
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||||
|
return models.User{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByID(ctx context.Context, id int) (models.User, error) {
|
||||||
|
var u models.User
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, phone, address, created_at FROM users WHERE id = $1
|
||||||
|
`, id).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) UpdateProfile(ctx context.Context, u *models.User) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET name = $1, phone = $2, address = $3 WHERE id = $4
|
||||||
|
`, u.Name, u.Phone, u.Address, u.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
// Version задаётся при сборке: -ldflags "-X shop/internal/version.Version=..."
|
||||||
|
var Version = "dev"
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- Пользователи, корзина, заказы
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS carts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
session_key VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_user ON carts (user_id) WHERE user_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_session ON carts (session_key) WHERE user_id IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cart_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
cart_id INT NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
|
||||||
|
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
UNIQUE (cart_id, product_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
guest_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
guest_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
guest_phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
total NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'new',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
product_name VARCHAR(255) NOT NULL,
|
||||||
|
price NUMERIC(10, 2) NOT NULL,
|
||||||
|
quantity INT NOT NULL CHECK (quantity > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id);
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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
@@ -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 "Сервисы запущены."
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Полная пересборка и перезапуск
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
|
||||||
|
echo "=== Shop redeploy ==="
|
||||||
|
|
||||||
|
if [[ -d .git ]]; then
|
||||||
|
echo "Обновление кода из git (сброс локальных правок скриптов)..."
|
||||||
|
git fetch origin main
|
||||||
|
git reset --hard origin/main
|
||||||
|
export APP_VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)
|
||||||
|
echo "Git: $(git log -1 --oneline)"
|
||||||
|
else
|
||||||
|
export APP_VERSION="manual"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
echo "Version: ${APP_VERSION}"
|
||||||
|
|
||||||
|
chmod +x scripts/*.sh 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Останавливаем app..."
|
||||||
|
docker compose stop app 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Пересборка образа (без кэша)..."
|
||||||
|
docker compose build --no-cache app
|
||||||
|
|
||||||
|
echo "Запуск контейнеров..."
|
||||||
|
docker compose up -d --force-recreate app caddy
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Ожидание запуска..."
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Проверка версии:"
|
||||||
|
curl -sf "http://127.0.0.1/health" 2>/dev/null || curl -sf "http://localhost/health" || docker compose exec -T app wget -qO- http://127.0.0.1:8080/health
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
IP=$(hostname -I | awk '{print $1}')
|
||||||
|
echo "Готово: http://${IP}"
|
||||||
|
echo "Логи: docker compose logs -f app"
|
||||||
+14
-2
@@ -4,8 +4,20 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
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 "Обновление завершено."
|
||||||
|
|||||||
Reference in New Issue
Block a user