From e000264bb15060f3636dc54467943cb322a96645 Mon Sep 17 00:00:00 2001 From: shop Date: Thu, 25 Jun 2026 17:29:43 +0300 Subject: [PATCH] v0.30: product pages, categories, sale prices and order status timeline --- README.md | 182 ++++++---------- cmd/shop/main.go | 10 +- docker-compose.yml | 1 + internal/database/migrate.go | 19 ++ internal/handlers/admin.go | 44 ++-- internal/handlers/admin_categories.go | 64 ++++++ internal/handlers/admin_products.go | 43 ++-- internal/handlers/customer.go | 55 ++++- internal/handlers/home.go | 23 +- internal/handlers/static/css/shop.css | 203 ++++++++++++++++++ internal/handlers/static/css/style.css | 29 ++- .../handlers/templates/admin_categories.html | 60 ++++++ internal/handlers/templates/admin_nav.html | 1 + .../templates/admin_product_form.html | 21 +- internal/handlers/templates/catalog.html | 99 +++++++++ internal/handlers/templates/home.html | 21 +- internal/handlers/templates/product_card.html | 30 +++ internal/handlers/templates/shop_header.html | 2 +- internal/handlers/templates/shop_pages.html | 31 ++- internal/models/product.go | 52 +++-- internal/orderstatus/status.go | 37 ++++ internal/repository/cart.go | 18 +- internal/repository/category.go | 125 +++++++++++ internal/repository/order.go | 2 +- internal/repository/product.go | 113 ++++++---- migrations/005_categories_sale.sql | 24 +++ 26 files changed, 1061 insertions(+), 248 deletions(-) create mode 100644 internal/handlers/admin_categories.go create mode 100644 internal/handlers/templates/admin_categories.html create mode 100644 internal/handlers/templates/catalog.html create mode 100644 internal/handlers/templates/product_card.html create mode 100644 internal/orderstatus/status.go create mode 100644 internal/repository/category.go create mode 100644 migrations/005_categories_sale.sql 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" .}} +
+

Категории

+ +
+

Добавить категорию

+
+ + + +
+
+ +
+ + + + + + + + + + + {{range .Categories}} + + + + + + + {{end}} + +
НазваниеSlugТоваров
{{.Name}}{{.Slug}}{{.Count}} +
+ +
+
+
+
+ + +{{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 @@
diff --git a/internal/handlers/templates/admin_product_form.html b/internal/handlers/templates/admin_product_form.html index 195f7a6..06f1765 100644 --- a/internal/handlers/templates/admin_product_form.html +++ b/internal/handlers/templates/admin_product_form.html @@ -44,18 +44,21 @@
+ +