v0.30: product pages, categories, sale prices and order status timeline
This commit is contained in:
+27
-17
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,7 @@
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin/" 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/products/new" class="admin-nav__link admin-nav__link--accent">+ Добавить</a>
|
||||
</nav>
|
||||
<div class="admin-header__user">
|
||||
|
||||
@@ -44,18 +44,21 @@
|
||||
<input type="number" name="price" class="admin-form__input" value="{{printf "%.0f" .Product.Price}}" min="0" step="1" required>
|
||||
</label>
|
||||
<label class="admin-form__label">
|
||||
Категория
|
||||
<input type="text" name="category" class="admin-form__input" value="{{.Product.Category}}" list="categories">
|
||||
<datalist id="categories">
|
||||
<option value="Электроника">
|
||||
<option value="Одежда">
|
||||
<option value="Дом и сад">
|
||||
<option value="Аксессуары">
|
||||
<option value="Разное">
|
||||
</datalist>
|
||||
Цена со скидкой (₽)
|
||||
<input type="number" name="sale_price" class="admin-form__input" value="{{if .Product.HasSale}}{{printf "%.0f" .Product.SalePrice}}{{end}}" min="0" step="1" placeholder="Без скидки">
|
||||
</label>
|
||||
</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">
|
||||
<input type="checkbox" name="is_active" {{if .Product.IsActive}}checked{{end}}>
|
||||
Показывать в каталоге
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
{{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>
|
||||
|
||||
<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>
|
||||
<p class="product-detail__short">{{plain .Product.Description}}</p>
|
||||
<form method="POST" action="/cart/add" class="product-detail__buy">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{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 "category.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Category.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>
|
||||
<span>/</span>
|
||||
<span>{{.Category.Name}}</span>
|
||||
</nav>
|
||||
<h1>{{.Category.Name}}</h1>
|
||||
{{if .Category.Description}}<p class="shop-hint">{{.Category.Description}}</p>{{end}}
|
||||
<div class="products">
|
||||
{{range .Products}}
|
||||
{{template "product_card" .}}
|
||||
{{else}}
|
||||
<p class="empty">В этой категории пока нет товаров.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -43,7 +43,7 @@
|
||||
<h2 class="section__title">Популярные категории</h2>
|
||||
<div class="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__count">{{.Count}} товаров</span>
|
||||
</a>
|
||||
@@ -60,24 +60,7 @@
|
||||
</div>
|
||||
<div class="products">
|
||||
{{range .Products}}
|
||||
<article class="product-card">
|
||||
<div class="product-card__image">
|
||||
<img src="{{.ImageURL}}" alt="{{.Name}}" loading="lazy">
|
||||
<span class="product-card__badge">{{.Category}}</span>
|
||||
</div>
|
||||
<div class="product-card__body">
|
||||
<h3 class="product-card__title">{{.Name}}</h3>
|
||||
<p class="product-card__desc">{{plain .Description}}</p>
|
||||
<div class="product-card__footer">
|
||||
<span class="product-card__price">{{printf "%.0f" .Price}} ₽</span>
|
||||
<form method="POST" action="/cart/add">
|
||||
<input type="hidden" name="product_id" value="{{.ID}}">
|
||||
<input type="hidden" name="quantity" value="1">
|
||||
<button type="submit" class="btn btn--primary btn--sm">В корзину</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{{template "product_card" .}}
|
||||
{{else}}
|
||||
<p class="empty">Товары скоро появятся в каталоге.</p>
|
||||
{{end}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -6,9 +6,9 @@
|
||||
<span class="logo__text">Shop</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="/#catalog" class="nav__link">Каталог</a>
|
||||
<a href="/#catalog" class="nav__link">Каталог</a>
|
||||
<a href="/#categories" class="nav__link">Категории</a>
|
||||
<a href="/#delivery" class="nav__link">Доставка</a>
|
||||
</nav>
|
||||
<div class="header__actions">
|
||||
{{if .User}}
|
||||
|
||||
@@ -19,8 +19,15 @@
|
||||
<div class="cart-item">
|
||||
<img src="{{.Product.ImageURL}}" alt="{{.Product.Name}}" class="cart-item__img">
|
||||
<div class="cart-item__info">
|
||||
<h3>{{.Product.Name}}</h3>
|
||||
<p class="cart-item__price">{{printf "%.0f" .Product.Price}} ₽</p>
|
||||
<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}}">
|
||||
@@ -164,10 +171,24 @@
|
||||
{{range .Orders}}
|
||||
<div class="order-card">
|
||||
<div class="order-card__head">
|
||||
<strong>Заказ #{{.ID}}</strong>
|
||||
<span>{{.CreatedAt.Format "02.01.2006 15:04"}}</span>
|
||||
<span class="admin-badge admin-badge--ok">{{.Status}}</span>
|
||||
<div>
|
||||
<strong>Заказ #{{.ID}}</strong>
|
||||
<span class="order-card__date">{{.CreatedAt.Format "02.01.2006 15:04"}}</span>
|
||||
</div>
|
||||
<span class="order-status {{orderClass .Status}}">{{orderLabel .Status}}</span>
|
||||
</div>
|
||||
|
||||
{{if ne .Status "cancelled"}}
|
||||
<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">{{orderLabel .}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<ul class="order-card__items">
|
||||
{{range .Items}}
|
||||
<li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li>
|
||||
|
||||
Reference in New Issue
Block a user