diff --git a/README.md b/README.md
index e08a84c..154e767 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,20 @@
# Shop — Интернет-магазин
-Главная страница интернет-магазина на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy** (обратный прокси + SSL).
+Полнофункциональный интернет-магазин на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy**.
-Проект настроен для развёртывания на **Ubuntu 22.04 / 24.04**.
+**Текущая версия:** `v0.30-beta`
+
+## Возможности
+
+| Модуль | Описание |
+|--------|----------|
+| Каталог | Главная, категории, карточки товаров со скидками |
+| Товар | Страница `/product/{id}` с описанием, фото и скриншотами |
+| Категории | `/category/{slug}` — управление в админке |
+| Корзина | Добавление, изменение количества, расчёт со скидкой |
+| Заказы | Оформление гостем или зарегистрированным пользователем |
+| Личный кабинет | Профиль, история заказов со статус-таймлайном |
+| Админка | Товары, категории, скриншоты, редактор Quill, статистика |
## Стек
@@ -12,164 +24,108 @@
| База данных | PostgreSQL 17 |
| Прокси / SSL | Caddy 2 |
| ОС | Ubuntu 22.04 / 24.04 |
-| Контейнеризация | Docker Compose |
-## Быстрый старт на Ubuntu
+## Быстрый старт (Ubuntu)
```bash
-# Клонировать репозиторий
git clone https://git.evilfox.cc/test2/shop-go.git /opt/shop
cd /opt/shop
-
-# 1. Установить Docker (один раз, с sudo)
-sudo ./scripts/install-ubuntu.sh
-
-# Перелогиниться или:
-newgrp docker
-
-# 2. Запустить магазин
-chmod +x scripts/*.sh
-./scripts/deploy.sh
+sudo bash setup.sh
```
-Откройте в браузере:
-- `http://IP_СЕРВЕРА` — магазин
-- `http://IP_СЕРВЕРА/admin/login` — админка
-
-Или через Make:
+Или обновление существующего:
```bash
-make install # sudo, установка Docker
-make deploy # первый запуск
-make logs # просмотр логов
+cd /opt/shop
+bash scripts/redeploy.sh
```
-## Продакшен с доменом и SSL
-
-1. Настройте DNS: A-запись домена → IP сервера Ubuntu
-2. Откройте порты 80 и 443 (скрипт `install-ubuntu.sh` настраивает ufw)
-3. Включите SSL:
+Проверка версии:
```bash
-./scripts/enable-ssl.sh shop.example.com admin@example.com
+curl http://localhost/health
+# {"status":"ok","version":"v0.30-beta"}
```
-Или:
+## Маршруты магазина
-```bash
-make ssl DOMAIN=shop.example.com EMAIL=admin@example.com
-```
+| Путь | Описание |
+|------|----------|
+| `/` | Главная |
+| `/product/{id}` | Страница товара |
+| `/category/{slug}` | Категория |
+| `/cart` | Корзина |
+| `/checkout` | Оформление заказа |
+| `/register`, `/login` | Регистрация и вход |
+| `/account` | Личный кабинет |
+| `/admin/` | Админ-панель |
-Caddy автоматически получит сертификат Let's Encrypt.
+## Скидки
-Вручную через `.env`:
+В админке при редактировании товара укажите **Цена** и **Цена со скидкой**.
+На сайте отображается старая цена зачёркнутой и бейдж `-N%`.
-```env
-DOMAIN=shop.example.com
-CADDY_EMAIL=admin@example.com
-CADDYFILE=./Caddyfile.prod
-```
+## Категории
-Затем: `docker compose up -d --force-recreate caddy`
+Админка → **Категории** → добавьте категорию → при создании товара выберите её в списке.
-## Автозапуск при загрузке Ubuntu
+## Статусы заказов
-```bash
-sudo cp systemd/shop.service /etc/systemd/system/
-# Отредактируйте WorkingDirectory, если проект не в /opt/shop
-sudo systemctl daemon-reload
-sudo systemctl enable --now shop
-```
+| Код | Отображение |
+|-----|-------------|
+| `new` | Новый |
+| `confirmed` | Подтверждён |
+| `processing` | В обработке |
+| `shipped` | Отправлен |
+| `delivered` | Доставлен |
+| `cancelled` | Отменён |
+
+В личном кабинете — цветной бейдж и таймлайн прогресса.
## Админ
-Администратор создаётся автоматически при старте из `.env`:
-
```env
ADMIN_EMAIL=admin@shop.local
ADMIN_PASSWORD=change_me
SESSION_SECRET=your_random_secret_key_here
```
-Скрипт `deploy.sh` генерирует пароли автоматически при первом запуске.
-
Вход: `/admin/login`
-При изменении `ADMIN_PASSWORD` пароль обновится в БД при следующем запуске.
-
-## Сервисы
-
-| Сервис | Порт | Описание |
-|--------|------|----------|
-| Caddy | 80, 443 | Обратный прокси, SSL |
-| App | 8080 (внутри) | Go-приложение |
-| PostgreSQL | 5432 (внутри) | База данных |
-
-## Обновление на сервере
+## SSL
```bash
-./scripts/update.sh
-# или
-make update
+./scripts/enable-ssl.sh shop.example.com admin@example.com
```
-## Локальная разработка (Ubuntu)
+## Обновление
```bash
-# Только PostgreSQL в Docker
-docker compose up db -d
-
-export DB_HOST=localhost DB_USER=shop DB_PASSWORD=shop_secret DB_NAME=shop
-export ADMIN_EMAIL=admin@shop.local ADMIN_PASSWORD=change_me
-
-go mod tidy
-go run ./cmd/shop
-# http://localhost:8080
+bash scripts/redeploy.sh
```
-## Структура проекта
+Скрипт делает `git reset --hard`, пересборку Docker без кэша и проверку `/health`.
+
+## Структура
```
shop/
-├── cmd/shop/main.go
+├── cmd/shop/ # Точка входа
├── internal/
+│ ├── handlers/ # HTTP, шаблоны, статика
+│ ├── repository/ # БД
+│ ├── orderstatus/ # Статусы заказов
+│ └── ...
├── migrations/
├── scripts/
-│ ├── install-ubuntu.sh # установка Docker + ufw
-│ ├── deploy.sh # первый запуск
-│ ├── enable-ssl.sh # SSL для домена
-│ └── update.sh # обновление
-├── systemd/shop.service # автозапуск
-├── Caddyfile # HTTP (IP / localhost)
-├── Caddyfile.prod # HTTPS (домен)
-├── docker-compose.yml
-├── Makefile
-└── Dockerfile
+│ ├── deploy.sh
+│ ├── redeploy.sh # Полное обновление
+│ └── update.sh
+└── docker-compose.yml
```
-## API
+## Требования
-| Метод | Путь | Описание |
-|-------|------|----------|
-| GET | `/` | Главная страница |
-| GET | `/health` | Health check |
-| GET | `/static/*` | CSS и статика |
-| GET | `/admin/login` | Страница входа |
-| POST | `/admin/login` | Авторизация |
-| GET | `/admin/` | Админ-панель |
-| POST | `/admin/logout` | Выход |
-
-## Остановка
-
-```bash
-docker compose down
-
-# С удалением данных БД
-docker compose down -v
-```
-
-## Требования к серверу Ubuntu
-
-- 1 CPU, 1 GB RAM (минимум)
-- Ubuntu 22.04 или 24.04 LTS
-- Открытые порты 22 (SSH), 80, 443
+- Ubuntu 22.04 / 24.04
+- 1 CPU, 1 GB RAM
+- Порты 22, 80, 443
diff --git a/cmd/shop/main.go b/cmd/shop/main.go
index 18fe0ea..e651246 100644
--- a/cmd/shop/main.go
+++ b/cmd/shop/main.go
@@ -34,6 +34,7 @@ func main() {
}
productRepo := repository.NewProductRepository(pool)
+ categoryRepo := repository.NewCategoryRepository(pool)
adminRepo := repository.NewAdminRepository(pool)
statsRepo := repository.NewStatsRepository(pool)
userRepo := repository.NewUserRepository(pool)
@@ -62,10 +63,10 @@ func main() {
cartSession := auth.NewSession(cfg.SessionSecret, "shop_cart_key", "/")
customer := handlers.NewCustomerHandler(
- productRepo, userRepo, cartRepo, orderRepo, statsRepo,
+ productRepo, categoryRepo, userRepo, cartRepo, orderRepo, statsRepo,
userSession, cartSession, tmpl,
)
- admin := handlers.NewAdminHandler(adminRepo, productRepo, statsRepo, storage, adminSession, tmpl)
+ admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, statsRepo, storage, adminSession, tmpl)
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler()))
@@ -73,6 +74,8 @@ func main() {
mux.HandleFunc("GET /health", customer.Health)
mux.HandleFunc("GET /{$}", customer.Home)
+ mux.HandleFunc("GET /product/{id}", customer.ProductPage)
+ mux.HandleFunc("GET /category/{slug}", customer.CategoryPage)
mux.HandleFunc("GET /register", customer.RegisterPage)
mux.HandleFunc("POST /register", customer.Register)
mux.HandleFunc("GET /login", customer.LoginPage)
@@ -97,6 +100,9 @@ func main() {
mux.HandleFunc("GET /admin/products/{id}/edit", admin.ProductEdit)
mux.HandleFunc("POST /admin/products/{id}/delete", admin.ProductDelete)
mux.HandleFunc("POST /admin/products/{id}/images/{imageId}/delete", admin.ImageDelete)
+ mux.HandleFunc("GET /admin/categories", admin.CategoryList)
+ mux.HandleFunc("POST /admin/categories", admin.CategoryCreate)
+ mux.HandleFunc("POST /admin/categories/{id}/delete", admin.CategoryDelete)
addr := ":" + getEnv("APP_PORT", "8080")
srv := &http.Server{
diff --git a/docker-compose.yml b/docker-compose.yml
index e5c3217..a32923a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -14,6 +14,7 @@ services:
- ./migrations/002_admins.sql:/docker-entrypoint-initdb.d/002_admins.sql:ro
- ./migrations/003_admin_features.sql:/docker-entrypoint-initdb.d/003_admin_features.sql:ro
- ./migrations/004_users_cart_orders.sql:/docker-entrypoint-initdb.d/004_users_cart_orders.sql:ro
+ - ./migrations/005_categories_sale.sql:/docker-entrypoint-initdb.d/005_categories_sale.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"]
interval: 5s
diff --git a/internal/database/migrate.go b/internal/database/migrate.go
index 8f7484a..80ae330 100644
--- a/internal/database/migrate.go
+++ b/internal/database/migrate.go
@@ -77,6 +77,25 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
quantity INT NOT NULL CHECK (quantity > 0)
)`,
`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id)`,
+ `CREATE TABLE IF NOT EXISTS categories (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(100) NOT NULL UNIQUE,
+ slug VARCHAR(100) NOT NULL UNIQUE,
+ description TEXT NOT NULL DEFAULT '',
+ sort_order INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )`,
+ `ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INT REFERENCES categories(id) ON DELETE SET NULL`,
+ `ALTER TABLE products ADD COLUMN IF NOT EXISTS sale_price NUMERIC(10,2)`,
+ `INSERT INTO categories (name, slug, sort_order) VALUES
+ ('Электроника', 'elektronika', 1),
+ ('Одежда', 'odezhda', 2),
+ ('Дом и сад', 'dom-i-sad', 3),
+ ('Аксессуары', 'aksessuary', 4),
+ ('Разное', 'raznoe', 5)
+ ON CONFLICT (slug) DO NOTHING`,
+ `UPDATE products p SET category_id = c.id
+ FROM categories c WHERE p.category = c.name AND p.category_id IS NULL`,
}
for _, q := range queries {
diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go
index ebdc8dc..d46002f 100644
--- a/internal/handlers/admin.go
+++ b/internal/handlers/admin.go
@@ -13,12 +13,13 @@ import (
)
type AdminHandler struct {
- repo *repository.AdminRepository
- products *repository.ProductRepository
- stats *repository.StatsRepository
- storage *upload.Storage
- session *auth.Session
- templates *template.Template
+ repo *repository.AdminRepository
+ products *repository.ProductRepository
+ categories *repository.CategoryRepository
+ stats *repository.StatsRepository
+ storage *upload.Storage
+ session *auth.Session
+ templates *template.Template
}
type adminLayoutData struct {
@@ -52,28 +53,37 @@ type adminProductsData struct {
}
type adminProductFormData struct {
- Title string
- Email string
- Product models.Product
- IsNew bool
- Error string
+ Title string
+ Email string
+ Product models.Product
+ Categories []models.Category
+ IsNew bool
+ Error string
+}
+
+type adminCategoriesData struct {
+ Title string
+ Email string
+ Categories []models.Category
}
func NewAdminHandler(
adminRepo *repository.AdminRepository,
productRepo *repository.ProductRepository,
+ categoryRepo *repository.CategoryRepository,
statsRepo *repository.StatsRepository,
storage *upload.Storage,
session *auth.Session,
templates *template.Template,
) *AdminHandler {
return &AdminHandler{
- repo: adminRepo,
- products: productRepo,
- stats: statsRepo,
- storage: storage,
- session: session,
- templates: templates,
+ repo: adminRepo,
+ products: productRepo,
+ categories: categoryRepo,
+ stats: statsRepo,
+ storage: storage,
+ session: session,
+ templates: templates,
}
}
diff --git a/internal/handlers/admin_categories.go b/internal/handlers/admin_categories.go
new file mode 100644
index 0000000..67c67ab
--- /dev/null
+++ b/internal/handlers/admin_categories.go
@@ -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
+}
diff --git a/internal/handlers/admin_products.go b/internal/handlers/admin_products.go
index 43b5b22..5d746bb 100644
--- a/internal/handlers/admin_products.go
+++ b/internal/handlers/admin_products.go
@@ -35,10 +35,11 @@ func (h *AdminHandler) ProductNew(w http.ResponseWriter, r *http.Request) {
}
h.render(w, "admin_product_form.html", adminProductFormData{
- Title: "Новый товар",
- Email: email,
- IsNew: true,
- Product: models.Product{IsActive: true, Category: "Разное"},
+ Title: "Новый товар",
+ Email: email,
+ IsNew: true,
+ Categories: h.loadCategories(r.Context()),
+ Product: models.Product{IsActive: true},
})
}
@@ -61,9 +62,10 @@ func (h *AdminHandler) ProductEdit(w http.ResponseWriter, r *http.Request) {
}
h.render(w, "admin_product_form.html", adminProductFormData{
- Title: "Редактирование товара",
- Email: email,
- Product: product,
+ Title: "Редактирование товара",
+ Email: email,
+ Product: product,
+ Categories: h.loadCategories(r.Context()),
})
}
@@ -82,13 +84,27 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
productID, _ := strconv.Atoi(r.FormValue("id"))
price, _ := strconv.ParseFloat(r.FormValue("price"), 64)
+ salePrice, _ := strconv.ParseFloat(r.FormValue("sale_price"), 64)
+ categoryID, _ := strconv.Atoi(r.FormValue("category_id"))
+
+ var catID *int
+ var categoryName string
+ if categoryID > 0 {
+ catID = &categoryID
+ if cat, err := h.categories.GetByID(ctx, categoryID); err == nil {
+ categoryName = cat.Name
+ }
+ }
+
product := models.Product{
ID: productID,
Name: r.FormValue("name"),
Description: r.FormValue("description"),
Details: r.FormValue("details"),
Price: price,
- Category: r.FormValue("category"),
+ SalePrice: salePrice,
+ Category: categoryName,
+ CategoryID: catID,
IsActive: r.FormValue("is_active") == "on",
ImageURL: r.FormValue("image_url"),
}
@@ -96,11 +112,12 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
if product.Name == "" || product.Price < 0 {
email, _ := h.requireAuth(w, r)
h.render(w, "admin_product_form.html", adminProductFormData{
- Title: "Ошибка",
- Email: email,
- Product: product,
- IsNew: isNew,
- Error: "Заполните название и цену",
+ Title: "Ошибка",
+ Email: email,
+ Product: product,
+ Categories: h.loadCategories(ctx),
+ IsNew: isNew,
+ Error: "Заполните название и цену",
})
return
}
diff --git a/internal/handlers/customer.go b/internal/handlers/customer.go
index c540ac3..6b2de78 100644
--- a/internal/handlers/customer.go
+++ b/internal/handlers/customer.go
@@ -15,6 +15,7 @@ import (
type CustomerHandler struct {
products *repository.ProductRepository
+ categories *repository.CategoryRepository
users *repository.UserRepository
cart *repository.CartRepository
orders *repository.OrderRepository
@@ -53,14 +54,26 @@ type checkoutPage struct {
type accountPage struct {
shopPage
- Orders []models.Order
- Name string
- Phone string
+ Orders []models.Order
+ Name string
+ Phone string
Address string
}
+type productPage struct {
+ shopPage
+ Product models.Product
+}
+
+type categoryPage struct {
+ shopPage
+ Category models.Category
+ Products []models.Product
+}
+
func NewCustomerHandler(
products *repository.ProductRepository,
+ categories *repository.CategoryRepository,
users *repository.UserRepository,
cart *repository.CartRepository,
orders *repository.OrderRepository,
@@ -69,7 +82,7 @@ func NewCustomerHandler(
templates *template.Template,
) *CustomerHandler {
return &CustomerHandler{
- products: products, users: users, cart: cart, orders: orders, stats: stats,
+ products: products, categories: categories, users: users, cart: cart, orders: orders, stats: stats,
userSession: userSession, cartSession: cartSession, templates: templates,
}
}
@@ -121,6 +134,40 @@ func (h *CustomerHandler) Home(w http.ResponseWriter, r *http.Request) {
h.render(w, "home.html", p)
}
+func (h *CustomerHandler) ProductPage(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ product, err := h.products.GetActiveByID(r.Context(), id)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ p := productPage{
+ shopPage: h.basePage(r, w, product.Name),
+ Product: product,
+ }
+ h.render(w, "product.html", p)
+}
+
+func (h *CustomerHandler) CategoryPage(w http.ResponseWriter, r *http.Request) {
+ slug := r.PathValue("slug")
+ cat, err := h.categories.GetBySlug(r.Context(), slug)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ products, _ := h.products.ByCategoryID(r.Context(), cat.ID)
+ p := categoryPage{
+ shopPage: h.basePage(r, w, cat.Name),
+ Category: cat,
+ Products: products,
+ }
+ h.render(w, "category.html", p)
+}
+
func (h *CustomerHandler) RegisterPage(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.UserIDFromSession(h.userSession, r); ok {
http.Redirect(w, r, "/account", http.StatusSeeOther)
diff --git a/internal/handlers/home.go b/internal/handlers/home.go
index ce76821..35b731d 100644
--- a/internal/handlers/home.go
+++ b/internal/handlers/home.go
@@ -7,6 +7,9 @@ import (
"log"
"net/http"
"regexp"
+
+ "shop/internal/models"
+ "shop/internal/orderstatus"
)
//go:embed templates/*
@@ -21,9 +24,27 @@ func stripHTML(s string) string {
return tagRe.ReplaceAllString(s, "")
}
+func effectivePrice(p models.Product) float64 { return p.EffectivePrice() }
+
func ParseTemplates() (*template.Template, error) {
funcMap := template.FuncMap{
- "plain": stripHTML,
+ "plain": stripHTML,
+ "effective": effectivePrice,
+ "safeHTML": func(s string) template.HTML { return template.HTML(s) },
+ "orderLabel": orderstatus.Label,
+ "orderClass": orderstatus.Class,
+ "orderStep": orderstatus.Step,
+ "orderTimeline": func() []string { return orderstatus.Timeline },
+ "stepDone": func(status, step string) bool {
+ cur, s := orderstatus.Step(status), orderstatus.Step(step)
+ return cur > 0 && s > 0 && s < cur
+ },
+ "stepCurrent": func(status, step string) bool {
+ return orderstatus.Step(status) == orderstatus.Step(step)
+ },
+ "catSelected": func(catID int, productCatID *int) bool {
+ return productCatID != nil && *productCatID == catID
+ },
}
return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
}
diff --git a/internal/handlers/static/css/shop.css b/internal/handlers/static/css/shop.css
index 51362b1..afc6cac 100644
--- a/internal/handlers/static/css/shop.css
+++ b/internal/handlers/static/css/shop.css
@@ -250,7 +250,210 @@
margin: 0;
}
+/* Цены со скидкой */
+.price-block {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.price-block__old {
+ text-decoration: line-through;
+ color: #9ca3af;
+ font-size: 0.9rem;
+}
+
+.price-block__sale,
+.price-block__current {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #ef4444;
+}
+
+.price-block--lg .price-block__sale,
+.price-block--lg .price-block__current {
+ font-size: 2rem;
+}
+
+.price-block__badge {
+ background: #fef2f2;
+ color: #b91c1c;
+ padding: 4px 10px;
+ border-radius: 100px;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+/* Страница товара */
+.breadcrumb {
+ display: flex;
+ gap: 8px;
+ font-size: 0.875rem;
+ color: #6b7280;
+ margin-bottom: 24px;
+ flex-wrap: wrap;
+}
+
+.breadcrumb a {
+ color: #4f46e5;
+ text-decoration: none;
+}
+
+.product-detail {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 40px;
+ margin-bottom: 40px;
+}
+
+.product-detail__main {
+ width: 100%;
+ border-radius: 16px;
+ border: 1px solid #e5e7eb;
+}
+
+.product-detail__thumbs {
+ display: flex;
+ gap: 8px;
+ margin-top: 12px;
+ flex-wrap: wrap;
+}
+
+.product-detail__thumbs img {
+ width: 72px;
+ height: 72px;
+ object-fit: cover;
+ border-radius: 8px;
+ border: 1px solid #e5e7eb;
+ cursor: pointer;
+}
+
+.product-detail__cat {
+ color: #4f46e5;
+ text-decoration: none;
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+.product-detail__info h1 {
+ font-size: 2rem;
+ margin: 12px 0 20px;
+}
+
+.product-detail__short {
+ color: #6b7280;
+ margin: 20px 0;
+ line-height: 1.6;
+}
+
+.product-detail__buy {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+
+.qty-input {
+ width: 72px;
+ padding: 12px;
+ border: 1px solid #e5e7eb;
+ border-radius: 10px;
+ font-size: 1rem;
+}
+
+.product-detail__html {
+ line-height: 1.7;
+ color: #374151;
+}
+
+.product-detail__html h1,
+.product-detail__html h2,
+.product-detail__html h3 {
+ margin: 1em 0 0.5em;
+}
+
+/* Статусы заказов */
+.order-card__head {
+ justify-content: space-between;
+}
+
+.order-card__date {
+ display: block;
+ font-size: 0.8rem;
+ color: #9ca3af;
+ margin-top: 2px;
+}
+
+.order-status {
+ padding: 6px 14px;
+ border-radius: 100px;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.status--new { background: #dbeafe; color: #1d4ed8; }
+.status--confirmed { background: #e0e7ff; color: #4338ca; }
+.status--processing { background: #ede9fe; color: #6d28d9; }
+.status--shipped { background: #ffedd5; color: #c2410c; }
+.status--delivered { background: #d1fae5; color: #065f46; }
+.status--cancelled { background: #f3f4f6; color: #6b7280; }
+
+.order-timeline {
+ display: flex;
+ gap: 4px;
+ margin: 16px 0;
+ padding: 16px 0;
+ overflow-x: auto;
+}
+
+.order-timeline__step {
+ flex: 1;
+ min-width: 80px;
+ text-align: center;
+ position: relative;
+ opacity: 0.35;
+}
+
+.order-timeline__step--done,
+.order-timeline__step--current {
+ opacity: 1;
+}
+
+.order-timeline__dot {
+ display: block;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #d1d5db;
+ margin: 0 auto 8px;
+}
+
+.order-timeline__step--done .order-timeline__dot {
+ background: #10b981;
+}
+
+.order-timeline__step--current .order-timeline__dot {
+ background: #4f46e5;
+ box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.2);
+}
+
+.order-timeline__label {
+ font-size: 0.7rem;
+ color: #6b7280;
+ line-height: 1.2;
+}
+
+.admin-form--row {
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: flex-end;
+}
+
@media (max-width: 768px) {
+ .product-detail {
+ grid-template-columns: 1fr;
+ }
+}
.cart-layout,
.checkout-layout,
.account-layout {
diff --git a/internal/handlers/static/css/style.css b/internal/handlers/static/css/style.css
index ac13f2c..eee7a23 100644
--- a/internal/handlers/static/css/style.css
+++ b/internal/handlers/static/css/style.css
@@ -337,8 +337,33 @@ body {
transition: transform 0.3s;
}
-.product-card:hover .product-card__image img {
- transform: scale(1.05);
+.product-card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+}
+
+.product-card__link {
+ text-decoration: none;
+ color: inherit;
+ flex: 1;
+}
+
+.product-card__cart {
+ padding: 0 20px 20px;
+ margin: 0;
+}
+
+.product-card__sale {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ background: #ef4444;
+ color: #fff;
+ padding: 4px 8px;
+ border-radius: 6px;
+ font-size: 0.75rem;
+ font-weight: 700;
}
.product-card__badge {
diff --git a/internal/handlers/templates/admin_categories.html b/internal/handlers/templates/admin_categories.html
new file mode 100644
index 0000000..67c2c0d
--- /dev/null
+++ b/internal/handlers/templates/admin_categories.html
@@ -0,0 +1,60 @@
+{{define "admin_categories.html"}}
+
+
+
+
+
+ {{.Title}}
+
+
+
+
+ {{template "admin_nav" .}}
+
+ Категории
+
+
+
+
+
+
+
+ | Название |
+ Slug |
+ Товаров |
+ |
+
+
+
+ {{range .Categories}}
+
+ | {{.Name}} |
+ {{.Slug}} |
+ {{.Count}} |
+
+
+ |
+
+ {{end}}
+
+
+
+
+
+
+{{end}}
diff --git a/internal/handlers/templates/admin_nav.html b/internal/handlers/templates/admin_nav.html
index 54bafb4..9c56ff7 100644
--- a/internal/handlers/templates/admin_nav.html
+++ b/internal/handlers/templates/admin_nav.html
@@ -8,6 +8,7 @@
+
+