6 Commits
51 changed files with 4539 additions and 405 deletions
+8
View File
@@ -10,6 +10,14 @@ ADMIN_PASSWORD=change_me
# Секрет для сессий (обязательно смените в продакшене) # Секрет для сессий (обязательно смените в продакшене)
SESSION_SECRET=your_random_secret_key_here SESSION_SECRET=your_random_secret_key_here
# Публичный URL магазина (для callback Heleket)
# PUBLIC_BASE_URL=https://shop.example.com
# Heleket — крипто-платежи (https://doc.heleket.com/ru)
# HELEKET_MERCHANT_ID=your-merchant-uuid
# HELEKET_PAYMENT_API_KEY=your-payment-api-key
# HELEKET_CURRENCY=RUB
# Часовой пояс сервера (Ubuntu) # Часовой пояс сервера (Ubuntu)
TZ=Europe/Moscow TZ=Europe/Moscow
+5 -1
View File
@@ -9,7 +9,11 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /shop ./cmd/shop ARG APP_VERSION=dev
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X shop/internal/version.Version=${APP_VERSION}" \
-o /shop ./cmd/shop
# Runtime stage # Runtime stage
FROM alpine:3.21 FROM alpine:3.21
+73 -113
View File
@@ -1,8 +1,20 @@
# Shop — Интернет-магазин # Shop — Интернет-магазин
Главная страница интернет-магазина на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy** (обратный прокси + SSL). Полнофункциональный интернет-магазин на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy**.
Проект настроен для развёртывания на **Ubuntu 22.04 / 24.04**. **Текущая версия:** `v1.0`
## Возможности
| Модуль | Описание |
|--------|----------|
| Каталог | Главная, категории, карточки товаров со скидками |
| Товар | Страница `/product/{id}` с описанием, фото и скриншотами |
| Категории | `/category/{slug}` — управление в админке |
| Корзина | Добавление, изменение количества, расчёт со скидкой |
| Заказы | Оформление гостем или зарегистрированным пользователем |
| Личный кабинет | Профиль, история заказов со статус-таймлайном |
| Админка | Товары, категории, заказы, скриншоты, редактор Quill, статистика |
## Стек ## Стек
@@ -12,164 +24,112 @@
| База данных | PostgreSQL 17 | | База данных | PostgreSQL 17 |
| Прокси / SSL | Caddy 2 | | Прокси / SSL | Caddy 2 |
| ОС | Ubuntu 22.04 / 24.04 | | ОС | Ubuntu 22.04 / 24.04 |
| Контейнеризация | Docker Compose |
## Быстрый старт на Ubuntu ## Быстрый старт (Ubuntu)
```bash ```bash
# Клонировать репозиторий
git clone https://git.evilfox.cc/test2/shop-go.git /opt/shop git clone https://git.evilfox.cc/test2/shop-go.git /opt/shop
cd /opt/shop cd /opt/shop
sudo bash setup.sh
# 1. Установить Docker (один раз, с sudo)
sudo ./scripts/install-ubuntu.sh
# Перелогиниться или:
newgrp docker
# 2. Запустить магазин
chmod +x scripts/*.sh
./scripts/deploy.sh
``` ```
Откройте в браузере: Или обновление существующего:
- `http://IP_СЕРВЕРА` — магазин
- `http://IP_СЕРВЕРА/admin/login` — админка
Или через Make:
```bash ```bash
make install # sudo, установка Docker cd /opt/shop
make deploy # первый запуск bash scripts/redeploy.sh
make logs # просмотр логов
``` ```
## Продакшен с доменом и SSL Проверка версии:
1. Настройте DNS: A-запись домена → IP сервера Ubuntu
2. Откройте порты 80 и 443 (скрипт `install-ubuntu.sh` настраивает ufw)
3. Включите SSL:
```bash ```bash
./scripts/enable-ssl.sh shop.example.com admin@example.com curl http://localhost/health
# {"status":"ok","version":"v1.0"}
``` ```
Или: ## Маршруты магазина
```bash | Путь | Описание |
make ssl DOMAIN=shop.example.com EMAIL=admin@example.com |------|----------|
``` | `/` | Главная |
| `/product/{id}` | Страница товара |
| `/category/{slug}` | Категория |
| `/cart` | Корзина |
| `/checkout` | Оформление заказа |
| `/register`, `/login` | Регистрация и вход |
| `/account` | Личный кабинет |
| `/admin/` | Админ-панель |
| `/admin/orders` | Управление заказами и статусами |
Caddy автоматически получит сертификат Let's Encrypt. ## Скидки
Вручную через `.env`: В админке при редактировании товара укажите **Цена** и **Цена со скидкой**.
На сайте отображается старая цена зачёркнутой и бейдж `-N%`.
```env ## Категории
DOMAIN=shop.example.com
CADDY_EMAIL=admin@example.com
CADDYFILE=./Caddyfile.prod
```
Затем: `docker compose up -d --force-recreate caddy` Админка → **Категории** → добавьте категорию → при создании товара выберите её в списке.
## Автозапуск при загрузке Ubuntu ## Статусы заказов
```bash | Код | Отображение |
sudo cp systemd/shop.service /etc/systemd/system/ |-----|-------------|
# Отредактируйте WorkingDirectory, если проект не в /opt/shop | `pending` | В ожидании |
sudo systemctl daemon-reload | `unpaid` | Не оплачено |
sudo systemctl enable --now shop | `paid` | Оплачено |
``` | `processing` | В обработке |
| `shipped` | Отправлен |
| `delivered` | Доставлен |
| `cancelled` | Отменён |
| `refunded` | Возврат |
В личном кабинете — цветной бейдж и таймлайн прогресса.
В админке → **Заказы** — смена статуса из выпадающего списка.
## Админ ## Админ
Администратор создаётся автоматически при старте из `.env`:
```env ```env
ADMIN_EMAIL=admin@shop.local ADMIN_EMAIL=admin@shop.local
ADMIN_PASSWORD=change_me ADMIN_PASSWORD=change_me
SESSION_SECRET=your_random_secret_key_here SESSION_SECRET=your_random_secret_key_here
``` ```
Скрипт `deploy.sh` генерирует пароли автоматически при первом запуске.
Вход: `/admin/login` Вход: `/admin/login`
При изменении `ADMIN_PASSWORD` пароль обновится в БД при следующем запуске. ## SSL
## Сервисы
| Сервис | Порт | Описание |
|--------|------|----------|
| Caddy | 80, 443 | Обратный прокси, SSL |
| App | 8080 (внутри) | Go-приложение |
| PostgreSQL | 5432 (внутри) | База данных |
## Обновление на сервере
```bash ```bash
./scripts/update.sh ./scripts/enable-ssl.sh shop.example.com admin@example.com
# или
make update
``` ```
## Локальная разработка (Ubuntu) ## Обновление
```bash ```bash
# Только PostgreSQL в Docker bash scripts/redeploy.sh
docker compose up db -d
export DB_HOST=localhost DB_USER=shop DB_PASSWORD=shop_secret DB_NAME=shop
export ADMIN_EMAIL=admin@shop.local ADMIN_PASSWORD=change_me
go mod tidy
go run ./cmd/shop
# http://localhost:8080
``` ```
## Структура проекта Скрипт делает `git reset --hard`, пересборку Docker без кэша и проверку `/health`.
## Структура
``` ```
shop/ shop/
├── cmd/shop/main.go ├── cmd/shop/ # Точка входа
├── internal/ ├── internal/
│ ├── handlers/ # HTTP, шаблоны, статика
│ ├── repository/ # БД
│ ├── orderstatus/ # Статусы заказов
│ └── ...
├── migrations/ ├── migrations/
├── scripts/ ├── scripts/
│ ├── install-ubuntu.sh # установка Docker + ufw │ ├── deploy.sh
│ ├── deploy.sh # первый запуск │ ├── redeploy.sh # Полное обновление
── enable-ssl.sh # SSL для домена ── update.sh
│ └── update.sh # обновление └── docker-compose.yml
├── systemd/shop.service # автозапуск
├── Caddyfile # HTTP (IP / localhost)
├── Caddyfile.prod # HTTPS (домен)
├── docker-compose.yml
├── Makefile
└── Dockerfile
``` ```
## API ## Требования
| Метод | Путь | Описание | - Ubuntu 22.04 / 24.04
|-------|------|----------| - 1 CPU, 1 GB RAM
| GET | `/` | Главная страница | - Порты 22, 80, 443
| GET | `/health` | Health check |
| GET | `/static/*` | CSS и статика |
| GET | `/admin/login` | Страница входа |
| POST | `/admin/login` | Авторизация |
| GET | `/admin/` | Админ-панель |
| POST | `/admin/logout` | Выход |
## Остановка
```bash
docker compose down
# С удалением данных БД
docker compose down -v
```
## Требования к серверу Ubuntu
- 1 CPU, 1 GB RAM (минимум)
- Ubuntu 22.04 или 24.04 LTS
- Открытые порты 22 (SSH), 80, 443
+63 -14
View File
@@ -13,9 +13,11 @@ import (
"shop/internal/config" "shop/internal/config"
"shop/internal/database" "shop/internal/database"
"shop/internal/handlers" "shop/internal/handlers"
"shop/internal/heleket"
"shop/internal/middleware" "shop/internal/middleware"
"shop/internal/repository" "shop/internal/repository"
"shop/internal/upload" "shop/internal/upload"
"shop/internal/version"
) )
func main() { func main() {
@@ -33,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 {
+8
View File
@@ -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
View File
@@ -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
}
+66
View File
@@ -0,0 +1,66 @@
package compare
import (
"strconv"
"strings"
)
const maxItems = 2
func Parse(raw string) []int {
if raw == "" {
return nil
}
var ids []int
for _, part := range strings.Split(raw, ",") {
id, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || id <= 0 {
continue
}
ids = appendUnique(ids, id)
}
return ids
}
func Format(ids []int) string {
if len(ids) == 0 {
return ""
}
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.Itoa(id)
}
return strings.Join(parts, ",")
}
func Add(raw string, id int) string {
if id <= 0 {
return raw
}
ids := Parse(raw)
ids = appendUnique(ids, id)
if len(ids) > maxItems {
ids = ids[len(ids)-maxItems:]
}
return Format(ids)
}
func Remove(raw string, id int) string {
ids := Parse(raw)
var next []int
for _, v := range ids {
if v != id {
next = append(next, v)
}
}
return Format(next)
}
func appendUnique(ids []int, id int) []int {
for _, v := range ids {
if v == id {
return ids
}
}
return append(ids, id)
}
+21 -8
View File
@@ -5,21 +5,34 @@ import (
"encoding/hex" "encoding/hex"
"log" "log"
"os" "os"
"strings"
) )
type Config struct { type Config struct {
AdminEmail string AdminEmail string
AdminPassword string AdminPassword string
SessionSecret string SessionSecret string
UploadDir string UploadDir string
PublicBaseURL string
HeleketMerchantID string
HeleketPaymentAPIKey string
HeleketCurrency string
} }
func Load() Config { func Load() Config {
cfg := Config{ cfg := Config{
AdminEmail: os.Getenv("ADMIN_EMAIL"), AdminEmail: os.Getenv("ADMIN_EMAIL"),
AdminPassword: os.Getenv("ADMIN_PASSWORD"), AdminPassword: os.Getenv("ADMIN_PASSWORD"),
SessionSecret: os.Getenv("SESSION_SECRET"), SessionSecret: os.Getenv("SESSION_SECRET"),
UploadDir: os.Getenv("UPLOAD_DIR"), UploadDir: os.Getenv("UPLOAD_DIR"),
PublicBaseURL: strings.TrimRight(os.Getenv("PUBLIC_BASE_URL"), "/"),
HeleketMerchantID: os.Getenv("HELEKET_MERCHANT_ID"),
HeleketPaymentAPIKey: os.Getenv("HELEKET_PAYMENT_API_KEY"),
HeleketCurrency: os.Getenv("HELEKET_CURRENCY"),
}
if cfg.HeleketCurrency == "" {
cfg.HeleketCurrency = "RUB"
} }
if cfg.UploadDir == "" { if cfg.UploadDir == "" {
+93
View File
@@ -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
View File
@@ -13,12 +13,15 @@ import (
) )
type AdminHandler struct { type AdminHandler struct {
repo *repository.AdminRepository repo *repository.AdminRepository
products *repository.ProductRepository products *repository.ProductRepository
stats *repository.StatsRepository categories *repository.CategoryRepository
storage *upload.Storage orders *repository.OrderRepository
session *auth.Session priceHistory *repository.PriceHistoryRepository
templates *template.Template stats *repository.StatsRepository
storage *upload.Storage
session *auth.Session
templates *template.Template
} }
type adminLayoutData struct { type adminLayoutData struct {
@@ -52,28 +55,41 @@ type adminProductsData struct {
} }
type adminProductFormData struct { type adminProductFormData struct {
Title string Title string
Email string Email string
Product models.Product Product models.Product
IsNew bool Categories []models.Category
Error string IsNew bool
Error string
}
type adminCategoriesData struct {
Title string
Email string
Categories []models.Category
} }
func NewAdminHandler( func NewAdminHandler(
adminRepo *repository.AdminRepository, adminRepo *repository.AdminRepository,
productRepo *repository.ProductRepository, productRepo *repository.ProductRepository,
categoryRepo *repository.CategoryRepository,
orderRepo *repository.OrderRepository,
priceHistory *repository.PriceHistoryRepository,
statsRepo *repository.StatsRepository, statsRepo *repository.StatsRepository,
storage *upload.Storage, storage *upload.Storage,
session *auth.Session, session *auth.Session,
templates *template.Template, templates *template.Template,
) *AdminHandler { ) *AdminHandler {
return &AdminHandler{ return &AdminHandler{
repo: adminRepo, repo: adminRepo,
products: productRepo, products: productRepo,
stats: statsRepo, categories: categoryRepo,
storage: storage, orders: orderRepo,
session: session, priceHistory: priceHistory,
templates: templates, stats: statsRepo,
storage: storage,
session: session,
templates: templates,
} }
} }
+64
View File
@@ -0,0 +1,64 @@
package handlers
import (
"context"
"log"
"net/http"
"strconv"
"shop/internal/models"
)
func (h *AdminHandler) CategoryList(w http.ResponseWriter, r *http.Request) {
email, ok := h.requireAuth(w, r)
if !ok {
return
}
list, err := h.categories.All(r.Context())
if err != nil {
log.Printf("categories: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
h.render(w, "admin_categories.html", adminCategoriesData{
Title: "Категории",
Email: email,
Categories: list,
})
}
func (h *AdminHandler) CategoryCreate(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireAuth(w, r); !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
desc := r.FormValue("description")
if name == "" {
http.Redirect(w, r, "/admin/categories", http.StatusSeeOther)
return
}
if _, err := h.categories.Create(r.Context(), name, desc); err != nil {
log.Printf("category create: %v", err)
}
http.Redirect(w, r, "/admin/categories", http.StatusSeeOther)
}
func (h *AdminHandler) CategoryDelete(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireAuth(w, r); !ok {
return
}
id, _ := strconv.Atoi(r.PathValue("id"))
if id > 0 {
_ = h.categories.Delete(r.Context(), id)
}
http.Redirect(w, r, "/admin/categories", http.StatusSeeOther)
}
func (h *AdminHandler) loadCategories(ctx context.Context) []models.Category {
list, _ := h.categories.All(ctx)
return list
}
+88
View File
@@ -0,0 +1,88 @@
package handlers
import (
"log"
"net/http"
"strconv"
"shop/internal/models"
"shop/internal/orderstatus"
)
type adminOrdersData struct {
Title string
Email string
Orders []models.Order
StatusList []orderStatusOption
Success string
}
type orderStatusOption struct {
Code string
Label string
}
func statusOptions() []orderStatusOption {
var list []orderStatusOption
for _, code := range orderstatus.AllCodes {
list = append(list, orderStatusOption{Code: code, Label: orderstatus.Label(code)})
}
return list
}
func (h *AdminHandler) OrderList(w http.ResponseWriter, r *http.Request) {
email, ok := h.requireAuth(w, r)
if !ok {
return
}
orders, err := h.orders.All(r.Context(), 200)
if err != nil {
log.Printf("admin orders: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
success := r.URL.Query().Get("ok")
msg := ""
if success == "1" {
msg = "Статус заказа обновлён"
}
h.render(w, "admin_orders.html", adminOrdersData{
Title: "Заказы",
Email: email,
Orders: orders,
StatusList: statusOptions(),
Success: msg,
})
}
func (h *AdminHandler) OrderUpdateStatus(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireAuth(w, r); !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
status := r.FormValue("status")
if !orderstatus.Valid(status) {
http.Redirect(w, r, "/admin/orders", http.StatusSeeOther)
return
}
if err := h.orders.UpdateStatus(r.Context(), id, status); err != nil {
log.Printf("order status: %v", err)
}
http.Redirect(w, r, "/admin/orders?ok=1", http.StatusSeeOther)
}
+46 -20
View File
@@ -35,10 +35,11 @@ func (h *AdminHandler) ProductNew(w http.ResponseWriter, r *http.Request) {
} }
h.render(w, "admin_product_form.html", adminProductFormData{ h.render(w, "admin_product_form.html", adminProductFormData{
Title: "Новый товар", Title: "Новый товар",
Email: email, Email: email,
IsNew: true, IsNew: true,
Product: models.Product{IsActive: true, Category: "Разное"}, Categories: h.loadCategories(r.Context()),
Product: models.Product{IsActive: true},
}) })
} }
@@ -61,9 +62,10 @@ func (h *AdminHandler) ProductEdit(w http.ResponseWriter, r *http.Request) {
} }
h.render(w, "admin_product_form.html", adminProductFormData{ h.render(w, "admin_product_form.html", adminProductFormData{
Title: "Редактирование товара", Title: "Редактирование товара",
Email: email, Email: email,
Product: product, Product: product,
Categories: h.loadCategories(r.Context()),
}) })
} }
@@ -82,25 +84,45 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
productID, _ := strconv.Atoi(r.FormValue("id")) productID, _ := strconv.Atoi(r.FormValue("id"))
price, _ := strconv.ParseFloat(r.FormValue("price"), 64) price, _ := strconv.ParseFloat(r.FormValue("price"), 64)
salePrice, _ := strconv.ParseFloat(r.FormValue("sale_price"), 64)
categoryID, _ := strconv.Atoi(r.FormValue("category_id"))
var catID *int
var categoryName string
if categoryID > 0 {
catID = &categoryID
if cat, err := h.categories.GetByID(ctx, categoryID); err == nil {
categoryName = cat.Name
}
}
product := models.Product{ product := models.Product{
ID: productID, ID: productID,
Name: r.FormValue("name"), Name: r.FormValue("name"),
Description: r.FormValue("description"), Description: r.FormValue("description"),
Details: r.FormValue("details"), Details: r.FormValue("details"),
Price: price, Price: price,
Category: r.FormValue("category"), SalePrice: salePrice,
IsActive: r.FormValue("is_active") == "on", Category: categoryName,
ImageURL: r.FormValue("image_url"), CategoryID: catID,
IsActive: r.FormValue("is_active") == "on",
ImageURL: r.FormValue("image_url"),
AttrSize: r.FormValue("attr_size"),
AttrColor: r.FormValue("attr_color"),
AttrMaterial: r.FormValue("attr_material"),
AttrWeight: r.FormValue("attr_weight"),
AttrBrand: r.FormValue("attr_brand"),
} }
if product.Name == "" || product.Price < 0 { if product.Name == "" || product.Price < 0 {
email, _ := h.requireAuth(w, r) email, _ := h.requireAuth(w, r)
h.render(w, "admin_product_form.html", adminProductFormData{ h.render(w, "admin_product_form.html", adminProductFormData{
Title: "Ошибка", Title: "Ошибка",
Email: email, Email: email,
Product: product, Product: product,
IsNew: isNew, Categories: h.loadCategories(ctx),
Error: "Заполните название и цену", IsNew: isNew,
Error: "Заполните название и цену",
}) })
return return
} }
@@ -111,6 +133,7 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
_ = h.priceHistory.Record(ctx, product.ID, product.Price, product.SalePrice)
} else { } else {
existing, err := h.products.GetByID(ctx, product.ID) existing, err := h.products.GetByID(ctx, product.ID)
if err != nil { if err != nil {
@@ -125,6 +148,9 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
if existing.Price != product.Price || existing.SalePrice != product.SalePrice {
_ = h.priceHistory.Record(ctx, product.ID, product.Price, product.SalePrice)
}
} }
if err := h.saveUploadedImages(r, product.ID, true); err != nil { if err := h.saveUploadedImages(r, product.ID, true); err != nil {
+560
View File
@@ -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)
}
+162
View File
@@ -0,0 +1,162 @@
package handlers
import (
"log"
"net/http"
"strconv"
"time"
"shop/internal/auth"
"shop/internal/compare"
"shop/internal/models"
)
func (h *CustomerHandler) BuyNow(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
productID, _ := strconv.Atoi(r.FormValue("product_id"))
qty, _ := strconv.Atoi(r.FormValue("quantity"))
if qty < 1 {
qty = 1
}
if productID <= 0 {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
cartID, err := h.resolveCart(r, w)
if err == nil {
_ = h.cart.AddItem(r.Context(), cartID, productID, qty)
}
http.Redirect(w, r, "/checkout", http.StatusSeeOther)
}
func (h *CustomerHandler) ProductReserve(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
ctx := r.Context()
if _, err := h.products.GetActiveByID(ctx, id); err != nil {
http.NotFound(w, r)
return
}
sessionKey := h.cartSession.EnsureKey(w, r)
if active, err := h.reservations.ActiveByProduct(ctx, id); err == nil {
if active.SessionKey != sessionKey {
var uid int
if u, ok := auth.UserIDFromSession(h.userSession, r); ok {
uid = u
}
if active.UserID == nil || uid == 0 || *active.UserID != uid {
http.Redirect(w, r, "/product/"+strconv.Itoa(id)+"?err=reserved", http.StatusSeeOther)
return
}
}
}
name := r.FormValue("name")
phone := r.FormValue("phone")
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
if u, err := h.users.GetByID(ctx, uid); err == nil {
if name == "" {
name = u.Name
}
if phone == "" {
phone = u.Phone
}
}
}
if name == "" {
name = "Гость"
}
res := models.Reservation{
ProductID: id,
SessionKey: sessionKey,
CustomerName: name,
CustomerPhone: phone,
ExpiresAt: time.Now().Add(h.reservations.ExpiresIn()),
}
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
res.UserID = &uid
}
if err := h.reservations.ReplaceForProduct(ctx, &res); err != nil {
log.Printf("reserve: %v", err)
http.Redirect(w, r, "/product/"+strconv.Itoa(id), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/product/"+strconv.Itoa(id)+"?ok=reserve", http.StatusSeeOther)
}
func (h *CustomerHandler) CompareAdd(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
productID, _ := strconv.Atoi(r.FormValue("product_id"))
otherID, _ := strconv.Atoi(r.FormValue("other_id"))
redirect := r.FormValue("redirect")
if redirect == "" {
redirect = "/compare"
}
raw, _ := h.compareSession.FromRequest(r)
next := raw
if productID > 0 {
next = compare.Add(next, productID)
}
if otherID > 0 {
next = compare.Add(next, otherID)
}
if next == raw && productID <= 0 {
http.Redirect(w, r, redirect, http.StatusSeeOther)
return
}
token, _ := h.compareSession.Create(next)
h.compareSession.SetCookie(w, r, token)
http.Redirect(w, r, redirect, http.StatusSeeOther)
}
func (h *CustomerHandler) CompareRemove(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
productID, _ := strconv.Atoi(r.FormValue("product_id"))
raw, _ := h.compareSession.FromRequest(r)
next := compare.Remove(raw, productID)
token, _ := h.compareSession.Create(next)
h.compareSession.SetCookie(w, r, token)
http.Redirect(w, r, "/compare", http.StatusSeeOther)
}
func (h *CustomerHandler) ComparePage(w http.ResponseWriter, r *http.Request) {
raw, _ := h.compareSession.FromRequest(r)
ids := compare.Parse(raw)
ctx := r.Context()
var products []models.Product
for _, id := range ids {
if p, err := h.products.GetActiveByID(ctx, id); err == nil {
products = append(products, p)
}
}
p := comparePage{
shopPage: h.basePage(r, w, "Сравнение товаров"),
Products: products,
}
h.render(w, "compare.html", p)
}
func (h *CustomerHandler) compareCount(r *http.Request) int {
raw, _ := h.compareSession.FromRequest(r)
return len(compare.Parse(raw))
}
+39 -70
View File
@@ -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"}`))
}
+110
View File
@@ -0,0 +1,110 @@
package handlers
import (
"io"
"log"
"net"
"net/http"
"strconv"
"shop/internal/heleket"
"shop/internal/models"
)
const heleketWebhookIP = "31.133.220.8"
func (h *CustomerHandler) HeleketWebhook(w http.ResponseWriter, r *http.Request) {
if !h.heleket.Enabled() {
http.NotFound(w, r)
return
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
host = fwd
}
if host != heleketWebhookIP && host != "" {
log.Printf("heleket webhook: note ip %s (expected %s)", host, heleketWebhookIP)
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
payload, ok := heleket.VerifyWebhook(body, h.heleketAPIKey)
if !ok {
log.Printf("heleket webhook: invalid signature")
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
orderIDStr, _ := payload["order_id"].(string)
orderID, ok := heleket.ParseOrderExternalID(orderIDStr)
if !ok {
log.Printf("heleket webhook: unknown order_id %q", orderIDStr)
w.WriteHeader(http.StatusOK)
return
}
paymentStatus, _ := payload["payment_status"].(string)
if paymentStatus == "" {
if s, ok := payload["status"].(string); ok {
paymentStatus = s
}
}
ctx := r.Context()
orderStatus := heleket.MapPaymentStatus(paymentStatus)
if err := h.orders.UpdateHeleketStatus(ctx, orderID, paymentStatus, orderStatus); err != nil {
log.Printf("heleket webhook update: %v", err)
}
w.WriteHeader(http.StatusOK)
}
type orderPayPage struct {
shopPage
Order models.Order
}
func (h *CustomerHandler) OrderSuccess(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
order, err := h.orders.GetByID(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
p := orderPayPage{
shopPage: h.basePage(r, w, "Заказ #"+strconv.Itoa(order.ID)),
Order: order,
}
h.render(w, "order_success.html", p)
}
func (h *CustomerHandler) OrderPay(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
order, err := h.orders.GetByID(r.Context(), id)
if err != nil || !order.IsHeleket() {
http.NotFound(w, r)
return
}
if order.HeleketPaymentURL != "" && !heleket.IsPaid(order.HeleketPaymentStatus) {
http.Redirect(w, r, order.HeleketPaymentURL, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/order/"+strconv.Itoa(id)+"/success", http.StatusSeeOther)
}
+56
View File
@@ -188,6 +188,55 @@
color: #9ca3af; color: #9ca3af;
} }
.admin-alert--ok {
background: #d1fae5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.admin-table__muted {
font-size: 0.8rem;
color: #9ca3af;
}
.admin-table--orders td {
vertical-align: top;
}
.admin-order-details td {
background: #f9fafb;
font-size: 0.85rem;
color: #6b7280;
padding-top: 0 !important;
}
.admin-order-items {
margin: 8px 0 0;
padding-left: 18px;
}
.admin-status-form {
display: flex;
gap: 8px;
align-items: center;
}
.admin-form__input--sm {
padding: 8px 10px;
font-size: 0.85rem;
min-width: 140px;
}
.admin-status-legend {
margin-top: 32px;
}
.admin-status-legend__grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.admin-hint code { .admin-hint code {
background: #e5e7eb; background: #e5e7eb;
padding: 2px 6px; padding: 2px 6px;
@@ -336,6 +385,13 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.admin-section__subtitle {
font-size: 0.95rem;
font-weight: 600;
margin: 20px 0 8px;
color: #374151;
}
.admin-editor { .admin-editor {
min-height: 160px; min-height: 160px;
background: #fff; background: #fff;
+730
View File
@@ -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;
}
}
+27 -2
View File
@@ -337,8 +337,33 @@ body {
transition: transform 0.3s; transition: transform 0.3s;
} }
.product-card:hover .product-card__image img { .product-card {
transform: scale(1.05); position: relative;
display: flex;
flex-direction: column;
}
.product-card__link {
text-decoration: none;
color: inherit;
flex: 1;
}
.product-card__cart {
padding: 0 20px 20px;
margin: 0;
}
.product-card__sale {
position: absolute;
top: 12px;
right: 12px;
background: #ef4444;
color: #fff;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 700;
} }
.product-card__badge { .product-card__badge {
@@ -0,0 +1,60 @@
{{define "admin_categories.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body class="admin-body">
{{template "admin_nav" .}}
<main class="admin-main container">
<h1 class="admin-page-title">Категории</h1>
<section class="admin-section">
<h2 class="admin-section__title">Добавить категорию</h2>
<form method="POST" action="/admin/categories" class="admin-form admin-form--row">
<label class="admin-form__label">
Название
<input type="text" name="name" class="admin-form__input" required>
</label>
<label class="admin-form__label">
Описание
<input type="text" name="description" class="admin-form__input">
</label>
<button type="submit" class="btn btn--primary">Добавить</button>
</form>
</section>
<section class="admin-section">
<table class="admin-table">
<thead>
<tr>
<th>Название</th>
<th>Slug</th>
<th>Товаров</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Categories}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td><a href="/category/{{.Slug}}" target="_blank">{{.Slug}}</a></td>
<td>{{.Count}}</td>
<td>
<form method="POST" action="/admin/categories/{{.ID}}/delete" onsubmit="return confirm('Удалить категорию?')">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
</main>
</body>
</html>
{{end}}
@@ -8,6 +8,8 @@
<nav class="admin-nav"> <nav class="admin-nav">
<a href="/admin/" class="admin-nav__link">Главная</a> <a href="/admin/" class="admin-nav__link">Главная</a>
<a href="/admin/products" class="admin-nav__link">Товары</a> <a href="/admin/products" class="admin-nav__link">Товары</a>
<a href="/admin/categories" class="admin-nav__link">Категории</a>
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
<a href="/admin/products/new" class="admin-nav__link admin-nav__link--accent">+ Добавить</a> <a href="/admin/products/new" class="admin-nav__link admin-nav__link--accent">+ Добавить</a>
</nav> </nav>
<div class="admin-header__user"> <div class="admin-header__user">
@@ -0,0 +1,93 @@
{{define "admin_orders.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="stylesheet" href="/static/css/shop.css">
</head>
<body class="admin-body">
{{template "admin_nav" .}}
<main class="admin-main container">
<h1 class="admin-page-title">Заказы</h1>
{{if .Success}}
<div class="admin-alert admin-alert--ok">{{.Success}}</div>
{{end}}
<section class="admin-section">
<table class="admin-table admin-table--orders">
<thead>
<tr>
<th>#</th>
<th>Дата</th>
<th>Клиент</th>
<th>Сумма</th>
<th>Статус</th>
<th>Изменить</th>
</tr>
</thead>
<tbody>
{{range .Orders}}
{{$orderStatus := .Status}}
<tr>
<td><strong>{{.ID}}</strong></td>
<td>{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
<td>
<div>{{.GuestName}}</div>
<div class="admin-table__muted">{{.GuestEmail}}</div>
<div class="admin-table__muted">{{.GuestPhone}}</div>
{{if eq .PaymentMethod "heleket"}}
<div class="admin-table__muted">Heleket {{if .HeleketPaymentStatus}}({{.HeleketPaymentStatus}}){{end}}</div>
{{end}}
</td>
<td>{{printf "%.0f" .Total}} ₽</td>
<td>
<span class="order-status {{orderClass .Status}}"><span class="order-status__icon">{{orderIcon .Status}}</span> {{orderLabel .Status}}</span>
</td>
<td>
<form method="POST" action="/admin/orders/{{.ID}}/status" class="admin-status-form">
<select name="status" class="admin-form__input admin-form__input--sm">
{{range $.StatusList}}
<option value="{{.Code}}" {{if eq .Code (orderNorm $orderStatus)}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
<button type="submit" class="btn btn--primary btn--sm">OK</button>
</form>
</td>
</tr>
<tr class="admin-order-details">
<td colspan="6">
<strong>Адрес:</strong> {{.Address}}
{{if .Items}}
<ul class="admin-order-items">
{{range .Items}}
<li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li>
{{end}}
</ul>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="6" class="admin-empty">Заказов пока нет</td></tr>
{{end}}
</tbody>
</table>
</section>
<div class="admin-status-legend">
<h3 class="admin-section__title">Статусы</h3>
<div class="admin-status-legend__grid">
{{range .StatusList}}
<span class="order-status {{orderClass .Code}}"><span class="order-status__icon">{{orderIcon .Code}}</span> {{.Label}}</span>
{{end}}
</div>
</div>
</main>
</body>
</html>
{{end}}
@@ -44,22 +44,53 @@
<input type="number" name="price" class="admin-form__input" value="{{printf "%.0f" .Product.Price}}" min="0" step="1" required> <input type="number" name="price" class="admin-form__input" value="{{printf "%.0f" .Product.Price}}" min="0" step="1" required>
</label> </label>
<label class="admin-form__label"> <label class="admin-form__label">
Категория Цена со скидкой (₽)
<input type="text" name="category" class="admin-form__input" value="{{.Product.Category}}" list="categories"> <input type="number" name="sale_price" class="admin-form__input" value="{{if .Product.HasSale}}{{printf "%.0f" .Product.SalePrice}}{{end}}" min="0" step="1" placeholder="Без скидки">
<datalist id="categories">
<option value="Электроника">
<option value="Одежда">
<option value="Дом и сад">
<option value="Аксессуары">
<option value="Разное">
</datalist>
</label> </label>
</div> </div>
<label class="admin-form__label">
Категория
<select name="category_id" class="admin-form__input">
<option value="">— без категории —</option>
{{range .Categories}}
<option value="{{.ID}}" {{if catSelected .ID $.Product.CategoryID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label class="admin-form__checkbox"> <label class="admin-form__checkbox">
<input type="checkbox" name="is_active" {{if .Product.IsActive}}checked{{end}}> <input type="checkbox" name="is_active" {{if .Product.IsActive}}checked{{end}}>
Показывать в каталоге Показывать в каталоге
</label> </label>
<h3 class="admin-section__subtitle">Характеристики</h3>
<p class="admin-form__hint">Пустые поля не отображаются на сайте</p>
<div class="admin-form-row">
<label class="admin-form__label">
Бренд
<input type="text" name="attr_brand" class="admin-form__input" value="{{.Product.AttrBrand}}" placeholder="Samsung">
</label>
<label class="admin-form__label">
Размер
<input type="text" name="attr_size" class="admin-form__input" value="{{.Product.AttrSize}}" placeholder="XL / 42">
</label>
</div>
<div class="admin-form-row">
<label class="admin-form__label">
Цвет
<input type="text" name="attr_color" class="admin-form__input" value="{{.Product.AttrColor}}" placeholder="Чёрный">
</label>
<label class="admin-form__label">
Материал
<input type="text" name="attr_material" class="admin-form__input" value="{{.Product.AttrMaterial}}" placeholder="Хлопок">
</label>
</div>
<label class="admin-form__label">
Вес
<input type="text" name="attr_weight" class="admin-form__input" value="{{.Product.AttrWeight}}" placeholder="1.2 кг">
</label>
</section> </section>
<section class="admin-section admin-form-col"> <section class="admin-section admin-form-col">
+57
View File
@@ -0,0 +1,57 @@
{{define "register.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<div class="shop-card">
<h1>Регистрация</h1>
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
<form method="POST" action="/register" class="shop-form">
<label>Имя<input type="text" name="name" required></label>
<label>Email<input type="email" name="email" required></label>
<label>Пароль (мин. 6 символов)<input type="password" name="password" required minlength="6"></label>
<button type="submit" class="btn btn--primary btn--lg">Зарегистрироваться</button>
</form>
<p class="shop-hint">Уже есть аккаунт? <a href="/login">Войти</a></p>
<p class="shop-hint">Можно покупать и <a href="/cart">без регистрации</a></p>
</div>
</main>
</body>
</html>
{{end}}
{{define "login.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<div class="shop-card">
<h1>Вход</h1>
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
<form method="POST" action="/login" class="shop-form">
<label>Email<input type="email" name="email" required></label>
<label>Пароль<input type="password" name="password" required></label>
<button type="submit" class="btn btn--primary btn--lg">Войти</button>
</form>
<p class="shop-hint">Нет аккаунта? <a href="/register">Регистрация</a></p>
</div>
</main>
</body>
</html>
{{end}}
+263
View File
@@ -0,0 +1,263 @@
{{define "product.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Product.Name}} — Shop</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<nav class="breadcrumb">
<a href="/">Главная</a>
{{if .Product.CategorySlug}}
<span>/</span>
<a href="/category/{{.Product.CategorySlug}}">{{.Product.Category}}</a>
{{end}}
<span>/</span>
<span>{{.Product.Name}}</span>
</nav>
{{if .Success}}<div class="shop-alert shop-alert--ok">{{.Success}}</div>{{end}}
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
{{if .ReservedByOther}}
<div class="shop-alert shop-alert--warn">
Товар забронирован до {{.Reservation.ExpiresAt.Format "02.01.2006 15:04"}}
</div>
{{else if .Reservation}}
<div class="shop-alert shop-alert--ok">
Вы забронировали этот товар до {{.Reservation.ExpiresAt.Format "02.01.2006 15:04"}}
</div>
{{end}}
<div class="product-detail">
<div class="product-detail__gallery">
<img src="{{.Product.ImageURL}}" alt="{{.Product.Name}}" class="product-detail__main">
{{if .Product.Images}}
<div class="product-detail__thumbs">
{{range .Product.Images}}
<img src="{{.FilePath}}" alt="">
{{end}}
</div>
{{end}}
</div>
<div class="product-detail__info">
{{if .Product.CategorySlug}}
<a href="/category/{{.Product.CategorySlug}}" class="product-detail__cat">{{.Product.Category}}</a>
{{end}}
<h1>{{.Product.Name}}</h1>
<div class="price-block price-block--lg">
{{if .Product.HasSale}}
<span class="price-block__old">{{printf "%.0f" .Product.Price}} ₽</span>
<span class="price-block__sale">{{printf "%.0f" .Product.SalePrice}} ₽</span>
<span class="price-block__badge">-{{.Product.DiscountPercent}}%</span>
{{else}}
<span class="price-block__current">{{printf "%.0f" .Product.Price}} ₽</span>
{{end}}
</div>
{{$specs := .Product.Specs}}
{{if $specs}}
<dl class="product-specs">
{{range $specs}}
<div class="product-specs__row">
<dt>{{.Label}}</dt>
<dd>{{.Value}}</dd>
</div>
{{end}}
</dl>
{{end}}
<p class="product-detail__short">{{plain .Product.Description}}</p>
<div class="product-actions">
<form method="POST" action="/buy" class="product-actions__form">
<input type="hidden" name="product_id" value="{{.Product.ID}}">
<input type="number" name="quantity" value="1" min="1" max="99" class="qty-input">
<button type="submit" class="btn btn--primary btn--lg">Купить</button>
</form>
<form method="POST" action="/cart/add" class="product-actions__form">
<input type="hidden" name="product_id" value="{{.Product.ID}}">
<input type="hidden" name="quantity" value="1">
<button type="submit" class="btn btn--ghost btn--lg">В корзину</button>
</form>
</div>
{{if not .ReservedByOther}}
<form method="POST" action="/product/{{.Product.ID}}/reserve" class="product-reserve">
<p class="shop-hint">Забронировать на 24 часа — товар будет отложен для вас</p>
<div class="product-reserve__row">
<input type="text" name="name" placeholder="Имя" class="shop-input">
<input type="tel" name="phone" placeholder="Телефон" class="shop-input">
<button type="submit" class="btn btn--secondary">Забронировать на 24 ч</button>
</div>
</form>
{{end}}
<div class="product-compare">
<form method="POST" action="/compare/add" class="product-compare__form">
<input type="hidden" name="product_id" value="{{.Product.ID}}">
<input type="hidden" name="redirect" value="/compare">
<button type="submit" class="btn btn--ghost btn--sm">⊕ Добавить к сравнению</button>
</form>
{{if .CompareProducts}}
<form method="POST" action="/compare/add" class="product-compare__form">
<input type="hidden" name="product_id" value="{{.Product.ID}}">
<select name="other_id" class="shop-input shop-input--sm" required>
<option value="">Сравнить с другим товаром…</option>
{{range .CompareProducts}}
<option value="{{.ID}}">{{.Name}} — {{printf "%.0f" .EffectivePrice}} ₽</option>
{{end}}
</select>
<input type="hidden" name="redirect" value="/compare">
<button type="submit" class="btn btn--ghost btn--sm">Сравнить</button>
</form>
{{end}}
</div>
</div>
</div>
{{if .PriceHistory}}
<section class="shop-card product-price-history">
<h2>История цены</h2>
<div class="price-history-chart">
{{range .PriceHistory}}
<div class="price-history-chart__bar" title="{{.RecordedAt.Format "02.01.2006"}} {{printf "%.0f" .Price}} ">
<div class="price-history-chart__fill" style="height: {{historyBarPct .Price $.MaxHistoryPrice}}%"></div>
<span class="price-history-chart__value">{{printf "%.0f" .Price}} ₽</span>
<span class="price-history-chart__label">{{.RecordedAt.Format "02.01"}}</span>
</div>
{{end}}
</div>
<table class="price-history-table">
<thead>
<tr><th>Дата</th><th>Цена</th><th>Со скидкой</th></tr>
</thead>
<tbody>
{{range .PriceHistory}}
<tr>
<td>{{.RecordedAt.Format "02.01.2006"}}</td>
<td>{{printf "%.0f" .Price}} ₽</td>
<td>{{if gt .SalePrice 0}}{{printf "%.0f" .SalePrice}} ₽{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{if .Product.Details}}
<section class="product-detail__content shop-card">
<h2>Описание</h2>
<div class="product-detail__html">{{safeHTML .Product.Details}}</div>
</section>
{{end}}
</main>
</body>
</html>
{{end}}
{{define "compare.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сравнение товаров — Shop</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/shop.css">
</head>
<body>
{{template "shop_header" .}}
<main class="shop-page container">
<h1>Сравнение товаров</h1>
{{if .Products}}
<div class="compare-table-wrap">
<table class="compare-table">
<thead>
<tr>
<th></th>
{{range .Products}}
<th>
<a href="/product/{{.ID}}">{{.Name}}</a>
<form method="POST" action="/compare/remove" class="compare-table__remove">
<input type="hidden" name="product_id" value="{{.ID}}">
<button type="submit" class="btn btn--ghost btn--sm">Убрать</button>
</form>
</th>
{{end}}
</tr>
</thead>
<tbody>
<tr>
<td>Фото</td>
{{range .Products}}
<td><img src="{{.ImageURL}}" alt="" class="compare-table__img"></td>
{{end}}
</tr>
<tr>
<td>Цена</td>
{{range .Products}}
<td>
{{if .HasSale}}
<span class="price-block__old">{{printf "%.0f" .Price}} ₽</span>
<strong>{{printf "%.0f" .SalePrice}} ₽</strong>
{{else}}
<strong>{{printf "%.0f" .Price}} ₽</strong>
{{end}}
</td>
{{end}}
</tr>
<tr>
<td>Категория</td>
{{range .Products}}<td>{{.Category}}</td>{{end}}
</tr>
<tr class="compare-table__spec">
<td>Бренд</td>
{{range .Products}}<td>{{if .AttrBrand}}{{.AttrBrand}}{{else}}—{{end}}</td>{{end}}
</tr>
<tr class="compare-table__spec">
<td>Размер</td>
{{range .Products}}<td>{{if .AttrSize}}{{.AttrSize}}{{else}}—{{end}}</td>{{end}}
</tr>
<tr class="compare-table__spec">
<td>Цвет</td>
{{range .Products}}<td>{{if .AttrColor}}{{.AttrColor}}{{else}}—{{end}}</td>{{end}}
</tr>
<tr class="compare-table__spec">
<td>Материал</td>
{{range .Products}}<td>{{if .AttrMaterial}}{{.AttrMaterial}}{{else}}—{{end}}</td>{{end}}
</tr>
<tr class="compare-table__spec">
<td>Вес</td>
{{range .Products}}<td>{{if .AttrWeight}}{{.AttrWeight}}{{else}}—{{end}}</td>{{end}}
</tr>
<tr>
<td></td>
{{range .Products}}
<td>
<form method="POST" action="/buy">
<input type="hidden" name="product_id" value="{{.ID}}">
<input type="hidden" name="quantity" value="1">
<button type="submit" class="btn btn--primary btn--sm">Купить</button>
</form>
</td>
{{end}}
</tr>
</tbody>
</table>
</div>
{{else}}
<p class="shop-hint">Добавьте до 2 товаров для сравнения со страницы товара.</p>
<a href="/" class="btn btn--primary">В каталог</a>
{{end}}
</main>
</body>
</html>
{{end}}
{{define "category.html"}}
+20 -55
View File
@@ -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}}
+254
View File
@@ -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}}
+138
View File
@@ -0,0 +1,138 @@
package heleket
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
const apiBase = "https://api.heleket.com"
type Client struct {
merchantID string
apiKey string
http *http.Client
}
type InvoiceRequest struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
OrderID string `json:"order_id"`
URLReturn string `json:"url_return,omitempty"`
URLSuccess string `json:"url_success,omitempty"`
URLCallback string `json:"url_callback,omitempty"`
PayerEmail string `json:"payer_email,omitempty"`
Lifetime int `json:"lifetime,omitempty"`
AdditionalData string `json:"additional_data,omitempty"`
}
type Invoice struct {
UUID string `json:"uuid"`
OrderID string `json:"order_id"`
Amount string `json:"amount"`
PaymentStatus string `json:"payment_status"`
URL string `json:"url"`
ExpiredAt int64 `json:"expired_at"`
IsFinal bool `json:"is_final"`
}
type apiResponse struct {
State int `json:"state"`
Result json.RawMessage `json:"result"`
Errors json.RawMessage `json:"errors"`
}
func NewClient(merchantID, apiKey string) *Client {
return &Client{
merchantID: merchantID,
apiKey: apiKey,
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) Enabled() bool {
return c.merchantID != "" && c.apiKey != ""
}
func OrderExternalID(orderID int) string {
return fmt.Sprintf("shop-order-%d", orderID)
}
func ParseOrderExternalID(orderID string) (int, bool) {
const prefix = "shop-order-"
if len(orderID) <= len(prefix) || orderID[:len(prefix)] != prefix {
return 0, false
}
id, err := strconv.Atoi(orderID[len(prefix):])
return id, err == nil && id > 0
}
func FormatAmount(amount float64) string {
return strconv.FormatFloat(amount, 'f', 2, 64)
}
func (c *Client) CreateInvoice(ctx context.Context, req InvoiceRequest) (Invoice, error) {
body, err := encodeBody(req)
if err != nil {
return Invoice{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/v1/payment", bytes.NewReader(body))
if err != nil {
return Invoice{}, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("merchant", c.merchantID)
httpReq.Header.Set("sign", SignBody(body, c.apiKey))
resp, err := c.http.Do(httpReq)
if err != nil {
return Invoice{}, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return Invoice{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Invoice{}, fmt.Errorf("heleket http %d: %s", resp.StatusCode, string(respBody))
}
var wrapped apiResponse
if err := json.Unmarshal(respBody, &wrapped); err != nil {
return Invoice{}, err
}
if wrapped.State != 0 {
return Invoice{}, fmt.Errorf("heleket api state %d: %s", wrapped.State, string(wrapped.Errors))
}
var invoice Invoice
if err := json.Unmarshal(wrapped.Result, &invoice); err != nil {
return Invoice{}, err
}
return invoice, nil
}
// MapPaymentStatus maps Heleket payment_status to shop order status.
func MapPaymentStatus(paymentStatus string) string {
switch paymentStatus {
case "paid", "paid_over":
return "paid"
case "cancel", "fail", "system_fail":
return "cancelled"
case "refund_paid":
return "refunded"
default:
return "unpaid"
}
}
func IsPaid(paymentStatus string) bool {
return paymentStatus == "paid" || paymentStatus == "paid_over"
}
+55
View File
@@ -0,0 +1,55 @@
package heleket
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"encoding/json"
"strings"
)
// encodeBody matches PHP json_encode default (slashes escaped, unicode unescaped).
func encodeBody(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
return []byte(strings.ReplaceAll(string(b), "/", `\/`)), nil
}
func SignBody(body []byte, apiKey string) string {
escaped := strings.ReplaceAll(string(body), "/", `\/`)
sum := md5.Sum([]byte(base64.StdEncoding.EncodeToString([]byte(escaped)) + apiKey))
return hex.EncodeToString(sum[:])
}
func VerifyWebhook(body []byte, apiKey string) (map[string]any, bool) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return nil, false
}
signRaw, ok := raw["sign"]
if !ok {
return nil, false
}
var sign string
if err := json.Unmarshal(signRaw, &sign); err != nil {
return nil, false
}
delete(raw, "sign")
payload, err := encodeBody(raw)
if err != nil {
return nil, false
}
if SignBody(payload, apiKey) != sign {
return nil, false
}
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
return nil, false
}
delete(data, "sign")
return data, true
}
+43 -14
View File
@@ -3,17 +3,43 @@ package models
import "time" import "time"
type Product struct { type Product struct {
ID int ID int
Name string Name string
Description string Description string
Details string Details string
Price float64 Price float64
ImageURL string SalePrice float64
Category string ImageURL string
IsActive bool Category string
CreatedAt time.Time CategoryID *int
UpdatedAt time.Time CategorySlug string
Images []ProductImage IsActive bool
AttrSize string
AttrColor string
AttrMaterial string
AttrWeight string
AttrBrand string
CreatedAt time.Time
UpdatedAt time.Time
Images []ProductImage
}
func (p Product) EffectivePrice() float64 {
if p.SalePrice > 0 && p.SalePrice < p.Price {
return p.SalePrice
}
return p.Price
}
func (p Product) HasSale() bool {
return p.SalePrice > 0 && p.SalePrice < p.Price
}
func (p Product) DiscountPercent() int {
if !p.HasSale() || p.Price <= 0 {
return 0
}
return int((1 - p.SalePrice/p.Price) * 100)
} }
type ProductImage struct { type ProductImage struct {
@@ -26,9 +52,12 @@ type ProductImage struct {
} }
type Category struct { type Category struct {
Name string ID int
Slug string Name string
Count int Slug string
Description string
SortOrder int
Count int
} }
type SiteStats struct { type SiteStats struct {
+47
View File
@@ -0,0 +1,47 @@
package models
import "time"
type ProductSpec struct {
Label string
Value string
}
func (p Product) Specs() []ProductSpec {
var specs []ProductSpec
if p.AttrBrand != "" {
specs = append(specs, ProductSpec{"Бренд", p.AttrBrand})
}
if p.AttrSize != "" {
specs = append(specs, ProductSpec{"Размер", p.AttrSize})
}
if p.AttrColor != "" {
specs = append(specs, ProductSpec{"Цвет", p.AttrColor})
}
if p.AttrMaterial != "" {
specs = append(specs, ProductSpec{"Материал", p.AttrMaterial})
}
if p.AttrWeight != "" {
specs = append(specs, ProductSpec{"Вес", p.AttrWeight})
}
return specs
}
type PriceHistoryEntry struct {
ID int
ProductID int
Price float64
SalePrice float64
RecordedAt time.Time
}
type Reservation struct {
ID int
ProductID int
SessionKey string
UserID *int
CustomerName string
CustomerPhone string
ExpiresAt time.Time
CreatedAt time.Time
}
+59
View File
@@ -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
}
+60
View File
@@ -0,0 +1,60 @@
package orderstatus
type Info struct {
Icon string
Label string
Class string
Step int
Terminal bool
}
var statuses = map[string]Info{
"pending": {Icon: "⏳", Label: "В ожидании", Class: "status--pending", Step: 1},
"unpaid": {Icon: "💳", Label: "Не оплачено", Class: "status--unpaid", Step: 1},
"paid": {Icon: "✓", Label: "Оплачено", Class: "status--paid", Step: 2},
"processing": {Icon: "⚙", Label: "В обработке", Class: "status--processing", Step: 3},
"shipped": {Icon: "📦", Label: "Отправлен", Class: "status--shipped", Step: 4},
"delivered": {Icon: "✓", Label: "Доставлен", Class: "status--delivered", Step: 5},
"cancelled": {Icon: "✕", Label: "Отменён", Class: "status--cancelled", Step: 0, Terminal: true},
"refunded": {Icon: "↩", Label: "Возврат", Class: "status--refunded", Step: 0, Terminal: true},
// совместимость со старыми заказами
"new": {Icon: "⏳", Label: "В ожидании", Class: "status--pending", Step: 1},
"confirmed": {Icon: "✓", Label: "Оплачено", Class: "status--paid", Step: 2},
}
// Timeline — основной путь доставки (для таймлайна в кабинете)
var Timeline = []string{"pending", "paid", "processing", "shipped", "delivered"}
// AllCodes — все статусы для выбора в админке
var AllCodes = []string{
"pending", "unpaid", "paid", "processing", "shipped", "delivered", "cancelled", "refunded",
}
func Normalize(code string) string {
if code == "new" {
return "pending"
}
if code == "confirmed" {
return "paid"
}
return code
}
func Get(code string) Info {
code = Normalize(code)
if s, ok := statuses[code]; ok {
return s
}
return Info{Label: code, Class: "status--pending", Step: 1}
}
func Label(code string) string { return Get(code).Label }
func Icon(code string) string { return Get(code).Icon }
func Class(code string) string { return Get(code).Class }
func Step(code string) int { return Get(code).Step }
func IsTerminal(code string) bool { return Get(code).Terminal }
func Valid(code string) bool {
_, ok := statuses[Normalize(code)]
return ok
}
+188
View File
@@ -0,0 +1,188 @@
package repository
import (
"context"
"database/sql"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type CartRepository struct {
pool *pgxpool.Pool
}
func NewCartRepository(pool *pgxpool.Pool) *CartRepository {
return &CartRepository{pool: pool}
}
func (r *CartRepository) GetOrCreate(ctx context.Context, sessionKey string, userID *int) (int, error) {
if userID != nil {
var cartID int
err := r.pool.QueryRow(ctx, `SELECT id FROM carts WHERE user_id = $1`, *userID).Scan(&cartID)
if err == nil {
return cartID, nil
}
if err != pgx.ErrNoRows {
return 0, err
}
err = r.pool.QueryRow(ctx, `
INSERT INTO carts (user_id, session_key) VALUES ($1, $2) RETURNING id
`, *userID, sessionKey).Scan(&cartID)
return cartID, err
}
var cartID int
err := r.pool.QueryRow(ctx, `
SELECT id FROM carts WHERE session_key = $1 AND user_id IS NULL
`, sessionKey).Scan(&cartID)
if err == nil {
return cartID, nil
}
if err != pgx.ErrNoRows {
return 0, err
}
err = r.pool.QueryRow(ctx, `
INSERT INTO carts (session_key) VALUES ($1) RETURNING id
`, sessionKey).Scan(&cartID)
return cartID, err
}
func (r *CartRepository) MergeToUser(ctx context.Context, sessionKey string, userID int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var guestCartID int
err = tx.QueryRow(ctx, `
SELECT id FROM carts WHERE session_key = $1 AND user_id IS NULL
`, sessionKey).Scan(&guestCartID)
if err == pgx.ErrNoRows {
return tx.Commit(ctx)
}
if err != nil {
return err
}
var userCartID int
err = tx.QueryRow(ctx, `SELECT id FROM carts WHERE user_id = $1`, userID).Scan(&userCartID)
if err == pgx.ErrNoRows {
_, err = tx.Exec(ctx, `UPDATE carts SET user_id = $1 WHERE id = $2`, userID, guestCartID)
if err != nil {
return err
}
return tx.Commit(ctx)
}
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
INSERT INTO cart_items (cart_id, product_id, quantity)
SELECT $1, product_id, quantity FROM cart_items WHERE cart_id = $2
ON CONFLICT (cart_id, product_id) DO UPDATE
SET quantity = cart_items.quantity + EXCLUDED.quantity
`, userCartID, guestCartID)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `DELETE FROM carts WHERE id = $1`, guestCartID)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func (r *CartRepository) AddItem(ctx context.Context, cartID, productID, qty int) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO cart_items (cart_id, product_id, quantity)
VALUES ($1, $2, $3)
ON CONFLICT (cart_id, product_id) DO UPDATE
SET quantity = cart_items.quantity + EXCLUDED.quantity
`, cartID, productID, qty)
return err
}
func (r *CartRepository) UpdateItem(ctx context.Context, cartID, productID, qty int) error {
if qty <= 0 {
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2`, cartID, productID)
return err
}
_, err := r.pool.Exec(ctx, `
UPDATE cart_items SET quantity = $3 WHERE cart_id = $1 AND product_id = $2
`, cartID, productID, qty)
return err
}
func (r *CartRepository) RemoveItem(ctx context.Context, cartID, productID int) error {
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2`, cartID, productID)
return err
}
func (r *CartRepository) Clear(ctx context.Context, cartID int) error {
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1`, cartID)
return err
}
func (r *CartRepository) ItemCount(ctx context.Context, cartID int) (int, error) {
var count int
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(SUM(quantity), 0) FROM cart_items WHERE cart_id = $1
`, cartID).Scan(&count)
return count, err
}
func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartItem, error) {
rows, err := r.pool.Query(ctx, `
SELECT ci.id, ci.cart_id, ci.product_id, ci.quantity,
p.id, p.name, p.description, p.details, p.price, COALESCE(p.sale_price, 0),
p.image_url, COALESCE(c.name, p.category, 'Разное'), p.category_id,
COALESCE(c.slug, ''), p.is_active, p.created_at, p.updated_at
FROM cart_items ci
JOIN products p ON p.id = ci.product_id
LEFT JOIN categories c ON c.id = p.category_id
WHERE ci.cart_id = $1 AND p.is_active = true
ORDER BY ci.id
`, cartID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []models.CartItem
for rows.Next() {
var ci models.CartItem
var p models.Product
var catID sql.NullInt64
if err := rows.Scan(
&ci.ID, &ci.CartID, &ci.ProductID, &ci.Quantity,
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
&p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
if catID.Valid {
id := int(catID.Int64)
p.CategoryID = &id
}
ci.Product = p
items = append(items, ci)
}
return items, rows.Err()
}
func (r *CartRepository) Total(ctx context.Context, cartID int) (float64, error) {
var total float64
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(SUM(ci.quantity * CASE
WHEN p.sale_price > 0 AND p.sale_price < p.price THEN p.sale_price
ELSE p.price END), 0)
FROM cart_items ci JOIN products p ON p.id = ci.product_id
WHERE ci.cart_id = $1
`, cartID).Scan(&total)
return total, err
}
+125
View File
@@ -0,0 +1,125 @@
package repository
import (
"context"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type CategoryRepository struct {
pool *pgxpool.Pool
}
func NewCategoryRepository(pool *pgxpool.Pool) *CategoryRepository {
return &CategoryRepository{pool: pool}
}
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
func Slugify(name string) string {
translit := map[rune]string{
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e",
'ж': "zh", 'з': "z", 'и': "i", 'й': "y", 'к': "k", 'л': "l", 'м': "m",
'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t", 'у': "u",
'ф': "f", 'х': "h", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "sch",
'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya",
}
var b strings.Builder
for _, r := range strings.ToLower(name) {
if t, ok := translit[r]; ok {
b.WriteString(t)
} else if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else if r == ' ' || r == '-' {
b.WriteRune('-')
}
}
s := slugRe.ReplaceAllString(b.String(), "-")
return strings.Trim(s, "-")
}
func (r *CategoryRepository) All(ctx context.Context) ([]models.Category, error) {
rows, err := r.pool.Query(ctx, `
SELECT c.id, c.name, c.slug, c.description, c.sort_order,
COUNT(p.id) FILTER (WHERE p.is_active = true) AS cnt
FROM categories c
LEFT JOIN products p ON p.category_id = c.id
GROUP BY c.id
ORDER BY c.sort_order, c.name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.Category
for rows.Next() {
var c models.Category
if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder, &c.Count); err != nil {
return nil, err
}
list = append(list, c)
}
return list, rows.Err()
}
func (r *CategoryRepository) GetBySlug(ctx context.Context, slug string) (models.Category, error) {
var c models.Category
err := r.pool.QueryRow(ctx, `
SELECT id, name, slug, description, sort_order FROM categories WHERE slug = $1
`, slug).Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder)
return c, err
}
func (r *CategoryRepository) GetByID(ctx context.Context, id int) (models.Category, error) {
var c models.Category
err := r.pool.QueryRow(ctx, `
SELECT id, name, slug, description, sort_order FROM categories WHERE id = $1
`, id).Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder)
return c, err
}
func (r *CategoryRepository) Create(ctx context.Context, name, description string) (models.Category, error) {
slug := Slugify(name)
var c models.Category
err := r.pool.QueryRow(ctx, `
INSERT INTO categories (name, slug, description)
VALUES ($1, $2, $3)
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id, name, slug, description, sort_order
`, name, slug, description).Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder)
return c, err
}
func (r *CategoryRepository) Update(ctx context.Context, c *models.Category) error {
tag, err := r.pool.Exec(ctx, `
UPDATE categories SET name = $1, slug = $2, description = $3, sort_order = $4 WHERE id = $5
`, c.Name, c.Slug, c.Description, c.SortOrder, c.ID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (r *CategoryRepository) Delete(ctx context.Context, id int) error {
_, err := r.pool.Exec(ctx, `UPDATE products SET category_id = NULL WHERE category_id = $1`, id)
if err != nil {
return err
}
tag, err := r.pool.Exec(ctx, `DELETE FROM categories WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
+197
View File
@@ -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()
}
+49
View File
@@ -0,0 +1,49 @@
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type PriceHistoryRepository struct {
pool *pgxpool.Pool
}
func NewPriceHistoryRepository(pool *pgxpool.Pool) *PriceHistoryRepository {
return &PriceHistoryRepository{pool: pool}
}
func (r *PriceHistoryRepository) Record(ctx context.Context, productID int, price, salePrice float64) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO product_price_history (product_id, price, sale_price)
VALUES ($1, $2, NULLIF($3, 0))
`, productID, price, salePrice)
return err
}
func (r *PriceHistoryRepository) ByProduct(ctx context.Context, productID int, limit int) ([]models.PriceHistoryEntry, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, product_id, price, COALESCE(sale_price, 0), recorded_at
FROM product_price_history
WHERE product_id = $1
ORDER BY recorded_at DESC
LIMIT $2
`, productID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []models.PriceHistoryEntry
for rows.Next() {
var e models.PriceHistoryEntry
if err := rows.Scan(&e.ID, &e.ProductID, &e.Price, &e.SalePrice, &e.RecordedAt); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
+80 -44
View File
@@ -2,8 +2,8 @@ package repository
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -19,25 +19,37 @@ func NewProductRepository(pool *pgxpool.Pool) *ProductRepository {
return &ProductRepository{pool: pool} return &ProductRepository{pool: pool}
} }
const productSelect = `
SELECT p.id, p.name, p.description, p.details, p.price, COALESCE(p.sale_price, 0),
p.image_url, COALESCE(c.name, p.category, 'Разное'), p.category_id,
COALESCE(c.slug, ''), p.is_active,
COALESCE(p.attr_size, ''), COALESCE(p.attr_color, ''), COALESCE(p.attr_material, ''),
COALESCE(p.attr_weight, ''), COALESCE(p.attr_brand, ''),
p.created_at, p.updated_at
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
`
func (r *ProductRepository) scanProduct(row pgx.Row) (models.Product, error) { func (r *ProductRepository) scanProduct(row pgx.Row) (models.Product, error) {
var p models.Product var p models.Product
var catID sql.NullInt64
err := row.Scan( err := row.Scan(
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
&p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt, &p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive,
&p.AttrSize, &p.AttrColor, &p.AttrMaterial, &p.AttrWeight, &p.AttrBrand,
&p.CreatedAt, &p.UpdatedAt,
) )
if catID.Valid {
id := int(catID.Int64)
p.CategoryID = &id
}
return p, err return p, err
} }
const productColumns = `id, name, description, details, price, image_url, category, is_active, created_at, updated_at`
func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.Product, error) { func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.Product, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, productSelect+`
SELECT `+productColumns+` WHERE p.is_active = true
FROM products ORDER BY p.created_at DESC LIMIT $1`, limit)
WHERE is_active = true
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -46,11 +58,18 @@ func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.P
} }
func (r *ProductRepository) All(ctx context.Context) ([]models.Product, error) { func (r *ProductRepository) All(ctx context.Context) ([]models.Product, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, productSelect+` ORDER BY p.created_at DESC`)
SELECT `+productColumns+` if err != nil {
FROM products return nil, err
ORDER BY created_at DESC }
`) defer rows.Close()
return r.collectProducts(rows)
}
func (r *ProductRepository) ByCategoryID(ctx context.Context, categoryID int) ([]models.Product, error) {
rows, err := r.pool.Query(ctx, productSelect+`
WHERE p.is_active = true AND p.category_id = $1
ORDER BY p.created_at DESC`, categoryID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -59,7 +78,17 @@ func (r *ProductRepository) All(ctx context.Context) ([]models.Product, error) {
} }
func (r *ProductRepository) GetByID(ctx context.Context, id int) (models.Product, error) { func (r *ProductRepository) GetByID(ctx context.Context, id int) (models.Product, error) {
row := r.pool.QueryRow(ctx, `SELECT `+productColumns+` FROM products WHERE id = $1`, id) row := r.pool.QueryRow(ctx, productSelect+` WHERE p.id = $1`, id)
p, err := r.scanProduct(row)
if err != nil {
return models.Product{}, err
}
p.Images, err = r.ImagesByProduct(ctx, id)
return p, err
}
func (r *ProductRepository) GetActiveByID(ctx context.Context, id int) (models.Product, error) {
row := r.pool.QueryRow(ctx, productSelect+` WHERE p.id = $1 AND p.is_active = true`, id)
p, err := r.scanProduct(row) p, err := r.scanProduct(row)
if err != nil { if err != nil {
return models.Product{}, err return models.Product{}, err
@@ -70,20 +99,24 @@ func (r *ProductRepository) GetByID(ctx context.Context, id int) (models.Product
func (r *ProductRepository) Create(ctx context.Context, p *models.Product) error { func (r *ProductRepository) Create(ctx context.Context, p *models.Product) error {
return r.pool.QueryRow(ctx, ` return r.pool.QueryRow(ctx, `
INSERT INTO products (name, description, details, price, image_url, category, is_active) INSERT INTO products (name, description, details, price, sale_price, image_url, category, category_id, is_active,
VALUES ($1, $2, $3, $4, $5, $6, $7) attr_size, attr_color, attr_material, attr_weight, attr_brand)
VALUES ($1, $2, $3, $4, NULLIF($5, 0), $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, created_at, updated_at RETURNING id, created_at, updated_at
`, p.Name, p.Description, p.Details, p.Price, p.ImageURL, p.Category, p.IsActive, `, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive,
p.AttrSize, p.AttrColor, p.AttrMaterial, p.AttrWeight, p.AttrBrand,
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) ).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
} }
func (r *ProductRepository) Update(ctx context.Context, p *models.Product) error { func (r *ProductRepository) Update(ctx context.Context, p *models.Product) error {
tag, err := r.pool.Exec(ctx, ` tag, err := r.pool.Exec(ctx, `
UPDATE products UPDATE products SET name = $1, description = $2, details = $3, price = $4,
SET name = $1, description = $2, details = $3, price = $4, sale_price = NULLIF($5, 0), image_url = $6, category = $7, category_id = $8,
image_url = $5, category = $6, is_active = $7, updated_at = NOW() is_active = $9, attr_size = $10, attr_color = $11, attr_material = $12,
WHERE id = $8 attr_weight = $13, attr_brand = $14, updated_at = NOW()
`, p.Name, p.Description, p.Details, p.Price, p.ImageURL, p.Category, p.IsActive, p.ID) WHERE id = $15
`, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive,
p.AttrSize, p.AttrColor, p.AttrMaterial, p.AttrWeight, p.AttrBrand, p.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -106,10 +139,12 @@ func (r *ProductRepository) Delete(ctx context.Context, id int) error {
func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category, error) { func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT category, COUNT(*) AS cnt SELECT c.id, c.name, c.slug, c.description, c.sort_order,
FROM products COUNT(p.id) FILTER (WHERE p.is_active = true)
GROUP BY category FROM categories c
ORDER BY cnt DESC LEFT JOIN products p ON p.category_id = c.id
GROUP BY c.id
ORDER BY c.sort_order, c.name
`) `)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -119,10 +154,9 @@ func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category,
var categories []models.Category var categories []models.Category
for rows.Next() { for rows.Next() {
var c models.Category var c models.Category
if err := rows.Scan(&c.Name, &c.Count); err != nil { if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.SortOrder, &c.Count); err != nil {
return nil, err return nil, err
} }
c.Slug = c.Name
categories = append(categories, c) categories = append(categories, c)
} }
return categories, rows.Err() return categories, rows.Err()
@@ -131,8 +165,7 @@ func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category,
func (r *ProductRepository) ImagesByProduct(ctx context.Context, productID int) ([]models.ProductImage, error) { func (r *ProductRepository) ImagesByProduct(ctx context.Context, productID int) ([]models.ProductImage, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT id, product_id, file_path, is_primary, sort_order, created_at SELECT id, product_id, file_path, is_primary, sort_order, created_at
FROM product_images FROM product_images WHERE product_id = $1
WHERE product_id = $1
ORDER BY is_primary DESC, sort_order ASC, id ASC ORDER BY is_primary DESC, sort_order ASC, id ASC
`, productID) `, productID)
if err != nil { if err != nil {
@@ -154,21 +187,17 @@ func (r *ProductRepository) ImagesByProduct(ctx context.Context, productID int)
func (r *ProductRepository) AddImage(ctx context.Context, img *models.ProductImage) error { func (r *ProductRepository) AddImage(ctx context.Context, img *models.ProductImage) error {
return r.pool.QueryRow(ctx, ` return r.pool.QueryRow(ctx, `
INSERT INTO product_images (product_id, file_path, is_primary, sort_order) INSERT INTO product_images (product_id, file_path, is_primary, sort_order)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4) RETURNING id, created_at
RETURNING id, created_at `, img.ProductID, img.FilePath, img.IsPrimary, img.SortOrder).Scan(&img.ID, &img.CreatedAt)
`, img.ProductID, img.FilePath, img.IsPrimary, img.SortOrder,
).Scan(&img.ID, &img.CreatedAt)
} }
func (r *ProductRepository) DeleteImage(ctx context.Context, imageID, productID int) (string, error) { func (r *ProductRepository) DeleteImage(ctx context.Context, imageID, productID int) (string, error) {
var path string var path string
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
DELETE FROM product_images DELETE FROM product_images WHERE id = $1 AND product_id = $2 RETURNING file_path
WHERE id = $1 AND product_id = $2
RETURNING file_path
`, imageID, productID).Scan(&path) `, imageID, productID).Scan(&path)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return "", fmt.Errorf("image not found") return "", errors.New("image not found")
} }
return path, err return path, err
} }
@@ -177,12 +206,19 @@ func (r *ProductRepository) collectProducts(rows pgx.Rows) ([]models.Product, er
var products []models.Product var products []models.Product
for rows.Next() { for rows.Next() {
var p models.Product var p models.Product
var catID sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
&p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt, &p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive,
&p.AttrSize, &p.AttrColor, &p.AttrMaterial, &p.AttrWeight, &p.AttrBrand,
&p.CreatedAt, &p.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if catID.Valid {
id := int(catID.Int64)
p.CategoryID = &id
}
products = append(products, p) products = append(products, p)
} }
return products, rows.Err() return products, rows.Err()
+79
View File
@@ -0,0 +1,79 @@
package repository
import (
"context"
"database/sql"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type ReservationRepository struct {
pool *pgxpool.Pool
}
func NewReservationRepository(pool *pgxpool.Pool) *ReservationRepository {
return &ReservationRepository{pool: pool}
}
func (r *ReservationRepository) Create(ctx context.Context, res *models.Reservation) error {
return r.pool.QueryRow(ctx, `
INSERT INTO product_reservations (product_id, session_key, user_id, customer_name, customer_phone, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at
`, res.ProductID, res.SessionKey, res.UserID, res.CustomerName, res.CustomerPhone, res.ExpiresAt,
).Scan(&res.ID, &res.CreatedAt)
}
func (r *ReservationRepository) ActiveByProduct(ctx context.Context, productID int) (*models.Reservation, error) {
var res models.Reservation
var userID sql.NullInt64
err := r.pool.QueryRow(ctx, `
SELECT id, product_id, session_key, user_id, customer_name, customer_phone, expires_at, created_at
FROM product_reservations
WHERE product_id = $1 AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
`, productID).Scan(
&res.ID, &res.ProductID, &res.SessionKey, &userID,
&res.CustomerName, &res.CustomerPhone, &res.ExpiresAt, &res.CreatedAt,
)
if err != nil {
return nil, err
}
if userID.Valid {
id := int(userID.Int64)
res.UserID = &id
}
return &res, nil
}
func (r *ReservationRepository) ReplaceForProduct(ctx context.Context, res *models.Reservation) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `DELETE FROM product_reservations WHERE product_id = $1`, res.ProductID)
if err != nil {
return err
}
err = tx.QueryRow(ctx, `
INSERT INTO product_reservations (product_id, session_key, user_id, customer_name, customer_phone, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at
`, res.ProductID, res.SessionKey, res.UserID, res.CustomerName, res.CustomerPhone, res.ExpiresAt,
).Scan(&res.ID, &res.CreatedAt)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func (r *ReservationRepository) ExpiresIn() time.Duration {
return 24 * time.Hour
}
+75
View File
@@ -0,0 +1,75 @@
package repository
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"shop/internal/models"
)
type UserRepository struct {
pool *pgxpool.Pool
}
func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
return &UserRepository{pool: pool}
}
func (r *UserRepository) Create(ctx context.Context, email, password, name string) (models.User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return models.User{}, err
}
var u models.User
err = r.pool.QueryRow(ctx, `
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, phone, address, created_at
`, email, string(hash), name).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt)
return u, err
}
func (r *UserRepository) Authenticate(ctx context.Context, email, password string) (models.User, error) {
var hash string
var u models.User
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, phone, address, created_at, password_hash
FROM users WHERE email = $1
`, email).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt, &hash)
if errors.Is(err, pgx.ErrNoRows) {
return models.User{}, pgx.ErrNoRows
}
if err != nil {
return models.User{}, err
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
return models.User{}, pgx.ErrNoRows
}
return u, nil
}
func (r *UserRepository) GetByID(ctx context.Context, id int) (models.User, error) {
var u models.User
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, phone, address, created_at FROM users WHERE id = $1
`, id).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt)
return u, err
}
func (r *UserRepository) UpdateProfile(ctx context.Context, u *models.User) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET name = $1, phone = $2, address = $3 WHERE id = $4
`, u.Name, u.Phone, u.Address, u.ID)
return err
}
func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email).Scan(&exists)
return exists, err
}
+4
View File
@@ -0,0 +1,4 @@
package version
// Version задаётся при сборке: -ldflags "-X shop/internal/version.Version=..."
var Version = "dev"
+52
View File
@@ -0,0 +1,52 @@
-- Пользователи, корзина, заказы
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
phone VARCHAR(50) NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS carts (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
session_key VARCHAR(64) NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_user ON carts (user_id) WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_session ON carts (session_key) WHERE user_id IS NULL;
CREATE TABLE IF NOT EXISTS cart_items (
id SERIAL PRIMARY KEY,
cart_id INT NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0),
UNIQUE (cart_id, product_id)
);
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
guest_name VARCHAR(255) NOT NULL DEFAULT '',
guest_email VARCHAR(255) NOT NULL DEFAULT '',
guest_phone VARCHAR(50) NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
total NUMERIC(10, 2) NOT NULL DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT 'new',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS order_items (
id SERIAL PRIMARY KEY,
order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INT NOT NULL,
product_name VARCHAR(255) NOT NULL,
price NUMERIC(10, 2) NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0)
);
CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id);
+24
View File
@@ -0,0 +1,24 @@
-- Категории и цена со скидкой
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INT REFERENCES categories(id) ON DELETE SET NULL;
ALTER TABLE products ADD COLUMN IF NOT EXISTS sale_price NUMERIC(10, 2);
INSERT INTO categories (name, slug, sort_order) VALUES
('Электроника', 'elektronika', 1),
('Одежда', 'odezhda', 2),
('Дом и сад', 'dom-i-sad', 3),
('Аксессуары', 'aksessuary', 4),
('Разное', 'raznoe', 5)
ON CONFLICT (slug) DO NOTHING;
UPDATE products p SET category_id = c.id
FROM categories c WHERE p.category = c.name AND p.category_id IS NULL;
+29
View File
@@ -0,0 +1,29 @@
ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_size VARCHAR(50) NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_color VARCHAR(50) NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_material VARCHAR(100) NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_weight VARCHAR(50) NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_brand VARCHAR(100) NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS product_price_history (
id SERIAL PRIMARY KEY,
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
price NUMERIC(10,2) NOT NULL,
sale_price NUMERIC(10,2),
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_price_history_product ON product_price_history (product_id, recorded_at DESC);
CREATE TABLE IF NOT EXISTS product_reservations (
id SERIAL PRIMARY KEY,
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
session_key VARCHAR(64) NOT NULL DEFAULT '',
user_id INT REFERENCES users(id) ON DELETE SET NULL,
customer_name VARCHAR(255) NOT NULL DEFAULT '',
customer_phone VARCHAR(50) NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_reservations_product ON product_reservations (product_id, expires_at DESC);
CREATE INDEX IF NOT EXISTS idx_reservations_session ON product_reservations (session_key);
+5
View File
@@ -0,0 +1,5 @@
ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cod';
ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_uuid VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_order_id VARCHAR(128) NOT NULL DEFAULT '';
ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_url TEXT NOT NULL DEFAULT '';
ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_status VARCHAR(50) NOT NULL DEFAULT '';
+5 -1
View File
@@ -25,8 +25,12 @@ if [[ ! -f .env ]]; then
echo "Проверьте ADMIN_EMAIL и DOMAIN перед продакшеном." echo "Проверьте ADMIN_EMAIL и DOMAIN перед продакшеном."
fi fi
export APP_VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
docker compose pull --ignore-buildable 2>/dev/null || true docker compose pull --ignore-buildable 2>/dev/null || true
docker compose up --build -d docker compose build app
docker compose up -d --force-recreate
echo "" echo ""
echo "Сервисы запущены." echo "Сервисы запущены."
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Полная пересборка и перезапуск
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
echo "=== Shop redeploy ==="
if [[ -d .git ]]; then
echo "Обновление кода из git (сброс локальных правок скриптов)..."
git fetch origin main
git reset --hard origin/main
export APP_VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)
echo "Git: $(git log -1 --oneline)"
else
export APP_VERSION="manual"
fi
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "Version: ${APP_VERSION}"
chmod +x scripts/*.sh 2>/dev/null || true
echo ""
echo "Останавливаем app..."
docker compose stop app 2>/dev/null || true
echo "Пересборка образа (без кэша)..."
docker compose build --no-cache app
echo "Запуск контейнеров..."
docker compose up -d --force-recreate app caddy
echo ""
echo "Ожидание запуска..."
sleep 3
echo ""
echo "Проверка версии:"
curl -sf "http://127.0.0.1/health" 2>/dev/null || curl -sf "http://localhost/health" || docker compose exec -T app wget -qO- http://127.0.0.1:8080/health
echo ""
echo ""
IP=$(hostname -I | awk '{print $1}')
echo "Готово: http://${IP}"
echo "Логи: docker compose logs -f app"
+14 -2
View File
@@ -4,8 +4,20 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}" cd "${ROOT_DIR}"
git pull origin main git fetch origin main
docker compose up --build -d git reset --hard origin/main
chmod +x scripts/*.sh 2>/dev/null || true
export APP_VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "Обновление до ${APP_VERSION}..."
docker compose build app
docker compose up -d --force-recreate --no-deps app caddy
docker image prune -f docker image prune -f
sleep 2
echo "Версия на сервере:"
curl -sf http://127.0.0.1/health || curl -sf http://localhost/health || true
echo ""
echo "Обновление завершено." echo "Обновление завершено."