Add product features, Heleket crypto payments and order status icons.
This commit is contained in:
@@ -16,8 +16,9 @@ type AdminHandler struct {
|
||||
repo *repository.AdminRepository
|
||||
products *repository.ProductRepository
|
||||
categories *repository.CategoryRepository
|
||||
orders *repository.OrderRepository
|
||||
stats *repository.StatsRepository
|
||||
orders *repository.OrderRepository
|
||||
priceHistory *repository.PriceHistoryRepository
|
||||
stats *repository.StatsRepository
|
||||
storage *upload.Storage
|
||||
session *auth.Session
|
||||
templates *template.Template
|
||||
@@ -73,6 +74,7 @@ func NewAdminHandler(
|
||||
productRepo *repository.ProductRepository,
|
||||
categoryRepo *repository.CategoryRepository,
|
||||
orderRepo *repository.OrderRepository,
|
||||
priceHistory *repository.PriceHistoryRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
storage *upload.Storage,
|
||||
session *auth.Session,
|
||||
@@ -82,8 +84,9 @@ func NewAdminHandler(
|
||||
repo: adminRepo,
|
||||
products: productRepo,
|
||||
categories: categoryRepo,
|
||||
orders: orderRepo,
|
||||
stats: statsRepo,
|
||||
orders: orderRepo,
|
||||
priceHistory: priceHistory,
|
||||
stats: statsRepo,
|
||||
storage: storage,
|
||||
session: session,
|
||||
templates: templates,
|
||||
|
||||
@@ -97,16 +97,21 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
product := models.Product{
|
||||
ID: productID,
|
||||
Name: r.FormValue("name"),
|
||||
Description: r.FormValue("description"),
|
||||
Details: r.FormValue("details"),
|
||||
Price: price,
|
||||
SalePrice: salePrice,
|
||||
Category: categoryName,
|
||||
CategoryID: catID,
|
||||
IsActive: r.FormValue("is_active") == "on",
|
||||
ImageURL: r.FormValue("image_url"),
|
||||
ID: productID,
|
||||
Name: r.FormValue("name"),
|
||||
Description: r.FormValue("description"),
|
||||
Details: r.FormValue("details"),
|
||||
Price: price,
|
||||
SalePrice: salePrice,
|
||||
Category: categoryName,
|
||||
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 {
|
||||
@@ -128,6 +133,7 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = h.priceHistory.Record(ctx, product.ID, product.Price, product.SalePrice)
|
||||
} else {
|
||||
existing, err := h.products.GetByID(ctx, product.ID)
|
||||
if err != nil {
|
||||
@@ -142,6 +148,9 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
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 {
|
||||
|
||||
+156
-25
@@ -8,6 +8,8 @@ import (
|
||||
"strconv"
|
||||
|
||||
"shop/internal/auth"
|
||||
"shop/internal/compare"
|
||||
"shop/internal/heleket"
|
||||
"shop/internal/models"
|
||||
"shop/internal/repository"
|
||||
"shop/internal/version"
|
||||
@@ -18,18 +20,26 @@ type CustomerHandler struct {
|
||||
categories *repository.CategoryRepository
|
||||
users *repository.UserRepository
|
||||
cart *repository.CartRepository
|
||||
orders *repository.OrderRepository
|
||||
stats *repository.StatsRepository
|
||||
userSession *auth.Session
|
||||
cartSession *auth.Session
|
||||
templates *template.Template
|
||||
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
|
||||
Version string
|
||||
CartCount int
|
||||
CompareCount int
|
||||
Version string
|
||||
Products interface{}
|
||||
Categories interface{}
|
||||
Error string
|
||||
@@ -44,12 +54,13 @@ type cartPage struct {
|
||||
|
||||
type checkoutPage struct {
|
||||
shopPage
|
||||
Items []models.CartItem
|
||||
Total float64
|
||||
Name string
|
||||
Email string
|
||||
Phone string
|
||||
Address string
|
||||
Items []models.CartItem
|
||||
Total float64
|
||||
Name string
|
||||
Email string
|
||||
Phone string
|
||||
Address string
|
||||
HeleketEnabled bool
|
||||
}
|
||||
|
||||
type accountPage struct {
|
||||
@@ -62,7 +73,20 @@ type accountPage struct {
|
||||
|
||||
type productPage struct {
|
||||
shopPage
|
||||
Product models.Product
|
||||
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 {
|
||||
@@ -77,13 +101,21 @@ func NewCustomerHandler(
|
||||
users *repository.UserRepository,
|
||||
cart *repository.CartRepository,
|
||||
orders *repository.OrderRepository,
|
||||
priceHistory *repository.PriceHistoryRepository,
|
||||
reservations *repository.ReservationRepository,
|
||||
stats *repository.StatsRepository,
|
||||
userSession, cartSession *auth.Session,
|
||||
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, stats: stats,
|
||||
userSession: userSession, cartSession: cartSession, templates: templates,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +140,7 @@ func (h *CustomerHandler) basePage(r *http.Request, w http.ResponseWriter, title
|
||||
p.CartCount = n
|
||||
}
|
||||
}
|
||||
p.CompareCount = h.compareCount(r)
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -140,15 +173,64 @@ func (h *CustomerHandler) ProductPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
product, err := h.products.GetActiveByID(r.Context(), id)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -321,9 +403,10 @@ func (h *CustomerHandler) CheckoutPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
total, _ := h.cart.Total(ctx, cartID)
|
||||
p := checkoutPage{
|
||||
shopPage: h.basePage(r, w, "Оформление заказа"),
|
||||
Items: items,
|
||||
Total: total,
|
||||
shopPage: h.basePage(r, w, "Оформление заказа"),
|
||||
Items: items,
|
||||
Total: total,
|
||||
HeleketEnabled: h.heleket.Enabled(),
|
||||
}
|
||||
if p.User != nil {
|
||||
p.Name = p.User.Name
|
||||
@@ -354,9 +437,14 @@ func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
address := r.FormValue("address")
|
||||
|
||||
p := checkoutPage{
|
||||
shopPage: h.basePage(r, w, "Оформление заказа"),
|
||||
Items: items, Total: total,
|
||||
Name: name, Email: email, Phone: phone, Address: address,
|
||||
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 = "Заполните все поля доставки"
|
||||
@@ -364,9 +452,22 @@ func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
Address: address, Total: total, PaymentMethod: paymentMethod,
|
||||
}
|
||||
if paymentMethod == "heleket" {
|
||||
order.Status = "unpaid"
|
||||
}
|
||||
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||
order.UserID = &uid
|
||||
@@ -379,6 +480,36 @@ func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
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, "Заказ оформлен")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func ParseTemplates() (*template.Template, error) {
|
||||
"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 },
|
||||
@@ -44,6 +45,16 @@ func ParseTemplates() (*template.Template, error) {
|
||||
},
|
||||
"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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -385,6 +385,13 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-section__subtitle {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin: 20px 0 8px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.admin-editor {
|
||||
min-height: 160px;
|
||||
background: #fff;
|
||||
|
||||
@@ -385,6 +385,9 @@
|
||||
}
|
||||
|
||||
.order-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 100px;
|
||||
font-size: 0.8rem;
|
||||
@@ -453,11 +456,268 @@
|
||||
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 {
|
||||
|
||||
@@ -41,10 +41,13 @@
|
||||
<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}}">{{orderLabel .Status}}</span>
|
||||
<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">
|
||||
@@ -80,7 +83,7 @@
|
||||
<h3 class="admin-section__title">Статусы</h3>
|
||||
<div class="admin-status-legend__grid">
|
||||
{{range .StatusList}}
|
||||
<span class="order-status {{orderClass .Code}}">{{.Label}}</span>
|
||||
<span class="order-status {{orderClass .Code}}"><span class="order-status__icon">{{orderIcon .Code}}</span> {{.Label}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,34 @@
|
||||
<input type="checkbox" name="is_active" {{if .Product.IsActive}}checked{{end}}>
|
||||
Показывать в каталоге
|
||||
</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 class="admin-section admin-form-col">
|
||||
|
||||
@@ -21,6 +21,19 @@
|
||||
<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">
|
||||
@@ -46,15 +59,97 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -66,34 +161,103 @@
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "category.html"}}
|
||||
{{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>{{.Category.Name}} — Shop</title>
|
||||
<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">
|
||||
<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}}
|
||||
<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"}}
|
||||
@@ -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="/compare" class="nav__link">Сравнение{{if .CompareCount}} ({{.CompareCount}}){{end}}</a>
|
||||
</nav>
|
||||
<div class="header__actions">
|
||||
{{if .User}}
|
||||
|
||||
@@ -86,6 +86,27 @@
|
||||
<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">
|
||||
@@ -118,9 +139,25 @@
|
||||
{{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}}
|
||||
@@ -175,7 +212,7 @@
|
||||
<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>
|
||||
<span class="order-status {{orderClass .Status}}"><span class="order-status__icon">{{orderIcon .Status}}</span> {{orderLabel .Status}}</span>
|
||||
</div>
|
||||
|
||||
{{if not (orderTerminal .Status)}}
|
||||
@@ -183,7 +220,7 @@
|
||||
{{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>
|
||||
<span class="order-timeline__label"><span class="order-status__icon">{{orderIcon .}}</span> {{orderLabel .}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -195,6 +232,15 @@
|
||||
{{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}}
|
||||
|
||||
Reference in New Issue
Block a user