diff --git a/.env.example b/.env.example index 3a39781..a33c3e2 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,14 @@ ADMIN_PASSWORD=change_me # Секрет для сессий (обязательно смените в продакшене) SESSION_SECRET=your_random_secret_key_here +# Публичный URL магазина (для callback Heleket) +# PUBLIC_BASE_URL=https://shop.example.com + +# Heleket — крипто-платежи (https://doc.heleket.com/ru) +# HELEKET_MERCHANT_ID=your-merchant-uuid +# HELEKET_PAYMENT_API_KEY=your-payment-api-key +# HELEKET_CURRENCY=RUB + # Часовой пояс сервера (Ubuntu) TZ=Europe/Moscow diff --git a/cmd/shop/main.go b/cmd/shop/main.go index de9b2d9..6f334c5 100644 --- a/cmd/shop/main.go +++ b/cmd/shop/main.go @@ -13,6 +13,7 @@ import ( "shop/internal/config" "shop/internal/database" "shop/internal/handlers" + "shop/internal/heleket" "shop/internal/middleware" "shop/internal/repository" "shop/internal/upload" @@ -41,6 +42,9 @@ func main() { cartRepo := repository.NewCartRepository(pool) orderRepo := repository.NewOrderRepository(pool) + priceHistoryRepo := repository.NewPriceHistoryRepository(pool) + reservationRepo := repository.NewReservationRepository(pool) + storage, err := upload.NewStorage(cfg.UploadDir) if err != nil { log.Fatalf("upload storage: %v", err) @@ -61,12 +65,21 @@ func main() { adminSession := auth.NewSession(cfg.SessionSecret, "shop_admin_session", "/admin") userSession := auth.NewSession(cfg.SessionSecret, "shop_user_session", "/") cartSession := auth.NewSession(cfg.SessionSecret, "shop_cart_key", "/") + compareSession := auth.NewSession(cfg.SessionSecret, "shop_compare", "/") + + heleketClient := heleket.NewClient(cfg.HeleketMerchantID, cfg.HeleketPaymentAPIKey) + if heleketClient.Enabled() { + log.Printf("heleket payments enabled (currency: %s)", cfg.HeleketCurrency) + } customer := handlers.NewCustomerHandler( - productRepo, categoryRepo, userRepo, cartRepo, orderRepo, statsRepo, - userSession, cartSession, tmpl, + productRepo, categoryRepo, userRepo, cartRepo, orderRepo, + priceHistoryRepo, reservationRepo, statsRepo, + userSession, cartSession, compareSession, + heleketClient, cfg.HeleketPaymentAPIKey, cfg.PublicBaseURL, cfg.HeleketCurrency, + tmpl, ) - admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, orderRepo, statsRepo, storage, adminSession, tmpl) + admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, orderRepo, priceHistoryRepo, statsRepo, storage, adminSession, tmpl) mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler())) @@ -75,6 +88,11 @@ func main() { mux.HandleFunc("GET /{$}", customer.Home) mux.HandleFunc("GET /product/{id}", customer.ProductPage) + mux.HandleFunc("POST /product/{id}/reserve", customer.ProductReserve) + mux.HandleFunc("POST /buy", customer.BuyNow) + mux.HandleFunc("GET /compare", customer.ComparePage) + mux.HandleFunc("POST /compare/add", customer.CompareAdd) + mux.HandleFunc("POST /compare/remove", customer.CompareRemove) mux.HandleFunc("GET /category/{slug}", customer.CategoryPage) mux.HandleFunc("GET /register", customer.RegisterPage) mux.HandleFunc("POST /register", customer.Register) @@ -87,6 +105,9 @@ func main() { mux.HandleFunc("POST /cart/remove", customer.CartRemove) mux.HandleFunc("GET /checkout", customer.CheckoutPage) mux.HandleFunc("POST /checkout", customer.Checkout) + mux.HandleFunc("GET /order/{id}/success", customer.OrderSuccess) + mux.HandleFunc("GET /order/{id}/pay", customer.OrderPay) + mux.HandleFunc("POST /webhooks/heleket", customer.HeleketWebhook) mux.HandleFunc("GET /account", customer.Account) mux.HandleFunc("POST /account", customer.Account) diff --git a/docker-compose.yml b/docker-compose.yml index a32923a..01b4aab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: - ./migrations/003_admin_features.sql:/docker-entrypoint-initdb.d/003_admin_features.sql:ro - ./migrations/004_users_cart_orders.sql:/docker-entrypoint-initdb.d/004_users_cart_orders.sql:ro - ./migrations/005_categories_sale.sql:/docker-entrypoint-initdb.d/005_categories_sale.sql:ro + - ./migrations/006_product_attrs_history.sql:/docker-entrypoint-initdb.d/006_product_attrs_history.sql:ro + - ./migrations/007_heleket.sql:/docker-entrypoint-initdb.d/007_heleket.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"] interval: 5s diff --git a/internal/compare/compare.go b/internal/compare/compare.go new file mode 100644 index 0000000..d501bc1 --- /dev/null +++ b/internal/compare/compare.go @@ -0,0 +1,66 @@ +package compare + +import ( + "strconv" + "strings" +) + +const maxItems = 2 + +func Parse(raw string) []int { + if raw == "" { + return nil + } + var ids []int + for _, part := range strings.Split(raw, ",") { + id, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || id <= 0 { + continue + } + ids = appendUnique(ids, id) + } + return ids +} + +func Format(ids []int) string { + if len(ids) == 0 { + return "" + } + parts := make([]string, len(ids)) + for i, id := range ids { + parts[i] = strconv.Itoa(id) + } + return strings.Join(parts, ",") +} + +func Add(raw string, id int) string { + if id <= 0 { + return raw + } + ids := Parse(raw) + ids = appendUnique(ids, id) + if len(ids) > maxItems { + ids = ids[len(ids)-maxItems:] + } + return Format(ids) +} + +func Remove(raw string, id int) string { + ids := Parse(raw) + var next []int + for _, v := range ids { + if v != id { + next = append(next, v) + } + } + return Format(next) +} + +func appendUnique(ids []int, id int) []int { + for _, v := range ids { + if v == id { + return ids + } + } + return append(ids, id) +} diff --git a/internal/config/config.go b/internal/config/config.go index b21533a..a4477a1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,21 +5,34 @@ import ( "encoding/hex" "log" "os" + "strings" ) type Config struct { - AdminEmail string - AdminPassword string - SessionSecret string - UploadDir string + AdminEmail string + AdminPassword string + SessionSecret string + UploadDir string + PublicBaseURL string + HeleketMerchantID string + HeleketPaymentAPIKey string + HeleketCurrency string } func Load() Config { cfg := Config{ - AdminEmail: os.Getenv("ADMIN_EMAIL"), - AdminPassword: os.Getenv("ADMIN_PASSWORD"), - SessionSecret: os.Getenv("SESSION_SECRET"), - UploadDir: os.Getenv("UPLOAD_DIR"), + AdminEmail: os.Getenv("ADMIN_EMAIL"), + AdminPassword: os.Getenv("ADMIN_PASSWORD"), + SessionSecret: os.Getenv("SESSION_SECRET"), + UploadDir: os.Getenv("UPLOAD_DIR"), + PublicBaseURL: strings.TrimRight(os.Getenv("PUBLIC_BASE_URL"), "/"), + HeleketMerchantID: os.Getenv("HELEKET_MERCHANT_ID"), + HeleketPaymentAPIKey: os.Getenv("HELEKET_PAYMENT_API_KEY"), + HeleketCurrency: os.Getenv("HELEKET_CURRENCY"), + } + + if cfg.HeleketCurrency == "" { + cfg.HeleketCurrency = "RUB" } if cfg.UploadDir == "" { diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 80ae330..ee9585c 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -96,6 +96,36 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error { ON CONFLICT (slug) DO NOTHING`, `UPDATE products p SET category_id = c.id FROM categories c WHERE p.category = c.name AND p.category_id IS NULL`, + `ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_size VARCHAR(50) NOT NULL DEFAULT ''`, + `ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_color VARCHAR(50) NOT NULL DEFAULT ''`, + `ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_material VARCHAR(100) NOT NULL DEFAULT ''`, + `ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_weight VARCHAR(50) NOT NULL DEFAULT ''`, + `ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_brand VARCHAR(100) NOT NULL DEFAULT ''`, + `CREATE TABLE IF NOT EXISTS product_price_history ( + id SERIAL PRIMARY KEY, + product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + price NUMERIC(10,2) NOT NULL, + sale_price NUMERIC(10,2), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_price_history_product ON product_price_history (product_id, recorded_at DESC)`, + `CREATE TABLE IF NOT EXISTS product_reservations ( + id SERIAL PRIMARY KEY, + product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + session_key VARCHAR(64) NOT NULL DEFAULT '', + user_id INT REFERENCES users(id) ON DELETE SET NULL, + customer_name VARCHAR(255) NOT NULL DEFAULT '', + customer_phone VARCHAR(50) NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_reservations_product ON product_reservations (product_id, expires_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_reservations_session ON product_reservations (session_key)`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cod'`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_uuid VARCHAR(64) NOT NULL DEFAULT ''`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_order_id VARCHAR(128) NOT NULL DEFAULT ''`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_url TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_status VARCHAR(50) NOT NULL DEFAULT ''`, } for _, q := range queries { diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go index e709a44..7d1d8e2 100644 --- a/internal/handlers/admin.go +++ b/internal/handlers/admin.go @@ -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, diff --git a/internal/handlers/admin_products.go b/internal/handlers/admin_products.go index 5d746bb..d294c92 100644 --- a/internal/handlers/admin_products.go +++ b/internal/handlers/admin_products.go @@ -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 { diff --git a/internal/handlers/customer.go b/internal/handlers/customer.go index 6b2de78..b896971 100644 --- a/internal/handlers/customer.go +++ b/internal/handlers/customer.go @@ -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, "Заказ оформлен") diff --git a/internal/handlers/customer_product.go b/internal/handlers/customer_product.go new file mode 100644 index 0000000..673e28c --- /dev/null +++ b/internal/handlers/customer_product.go @@ -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)) +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go index 1b2c755..6068e44 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -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 }, diff --git a/internal/handlers/payment.go b/internal/handlers/payment.go new file mode 100644 index 0000000..d0f88e7 --- /dev/null +++ b/internal/handlers/payment.go @@ -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) +} diff --git a/internal/handlers/static/css/admin.css b/internal/handlers/static/css/admin.css index 5215085..ae24302 100644 --- a/internal/handlers/static/css/admin.css +++ b/internal/handlers/static/css/admin.css @@ -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; diff --git a/internal/handlers/static/css/shop.css b/internal/handlers/static/css/shop.css index c31cfa4..5b930b8 100644 --- a/internal/handlers/static/css/shop.css +++ b/internal/handlers/static/css/shop.css @@ -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 { diff --git a/internal/handlers/templates/admin_orders.html b/internal/handlers/templates/admin_orders.html index a8fb5a8..99ba2d0 100644 --- a/internal/handlers/templates/admin_orders.html +++ b/internal/handlers/templates/admin_orders.html @@ -41,10 +41,13 @@