Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a0bb01e2 |
@@ -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
|
||||
|
||||
|
||||
+24
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package heleket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiBase = "https://api.heleket.com"
|
||||
|
||||
type Client struct {
|
||||
merchantID string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type InvoiceRequest struct {
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
OrderID string `json:"order_id"`
|
||||
URLReturn string `json:"url_return,omitempty"`
|
||||
URLSuccess string `json:"url_success,omitempty"`
|
||||
URLCallback string `json:"url_callback,omitempty"`
|
||||
PayerEmail string `json:"payer_email,omitempty"`
|
||||
Lifetime int `json:"lifetime,omitempty"`
|
||||
AdditionalData string `json:"additional_data,omitempty"`
|
||||
}
|
||||
|
||||
type Invoice struct {
|
||||
UUID string `json:"uuid"`
|
||||
OrderID string `json:"order_id"`
|
||||
Amount string `json:"amount"`
|
||||
PaymentStatus string `json:"payment_status"`
|
||||
URL string `json:"url"`
|
||||
ExpiredAt int64 `json:"expired_at"`
|
||||
IsFinal bool `json:"is_final"`
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
State int `json:"state"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Errors json.RawMessage `json:"errors"`
|
||||
}
|
||||
|
||||
func NewClient(merchantID, apiKey string) *Client {
|
||||
return &Client{
|
||||
merchantID: merchantID,
|
||||
apiKey: apiKey,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Enabled() bool {
|
||||
return c.merchantID != "" && c.apiKey != ""
|
||||
}
|
||||
|
||||
func OrderExternalID(orderID int) string {
|
||||
return fmt.Sprintf("shop-order-%d", orderID)
|
||||
}
|
||||
|
||||
func ParseOrderExternalID(orderID string) (int, bool) {
|
||||
const prefix = "shop-order-"
|
||||
if len(orderID) <= len(prefix) || orderID[:len(prefix)] != prefix {
|
||||
return 0, false
|
||||
}
|
||||
id, err := strconv.Atoi(orderID[len(prefix):])
|
||||
return id, err == nil && id > 0
|
||||
}
|
||||
|
||||
func FormatAmount(amount float64) string {
|
||||
return strconv.FormatFloat(amount, 'f', 2, 64)
|
||||
}
|
||||
|
||||
func (c *Client) CreateInvoice(ctx context.Context, req InvoiceRequest) (Invoice, error) {
|
||||
body, err := encodeBody(req)
|
||||
if err != nil {
|
||||
return Invoice{}, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/v1/payment", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Invoice{}, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("merchant", c.merchantID)
|
||||
httpReq.Header.Set("sign", SignBody(body, c.apiKey))
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return Invoice{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return Invoice{}, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return Invoice{}, fmt.Errorf("heleket http %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var wrapped apiResponse
|
||||
if err := json.Unmarshal(respBody, &wrapped); err != nil {
|
||||
return Invoice{}, err
|
||||
}
|
||||
if wrapped.State != 0 {
|
||||
return Invoice{}, fmt.Errorf("heleket api state %d: %s", wrapped.State, string(wrapped.Errors))
|
||||
}
|
||||
|
||||
var invoice Invoice
|
||||
if err := json.Unmarshal(wrapped.Result, &invoice); err != nil {
|
||||
return Invoice{}, err
|
||||
}
|
||||
return invoice, nil
|
||||
}
|
||||
|
||||
// MapPaymentStatus maps Heleket payment_status to shop order status.
|
||||
func MapPaymentStatus(paymentStatus string) string {
|
||||
switch paymentStatus {
|
||||
case "paid", "paid_over":
|
||||
return "paid"
|
||||
case "cancel", "fail", "system_fail":
|
||||
return "cancelled"
|
||||
case "refund_paid":
|
||||
return "refunded"
|
||||
default:
|
||||
return "unpaid"
|
||||
}
|
||||
}
|
||||
|
||||
func IsPaid(paymentStatus string) bool {
|
||||
return paymentStatus == "paid" || paymentStatus == "paid_over"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package heleket
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// encodeBody matches PHP json_encode default (slashes escaped, unicode unescaped).
|
||||
func encodeBody(v any) ([]byte, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(strings.ReplaceAll(string(b), "/", `\/`)), nil
|
||||
}
|
||||
|
||||
func SignBody(body []byte, apiKey string) string {
|
||||
escaped := strings.ReplaceAll(string(body), "/", `\/`)
|
||||
sum := md5.Sum([]byte(base64.StdEncoding.EncodeToString([]byte(escaped)) + apiKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func VerifyWebhook(body []byte, apiKey string) (map[string]any, bool) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
signRaw, ok := raw["sign"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
var sign string
|
||||
if err := json.Unmarshal(signRaw, &sign); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
delete(raw, "sign")
|
||||
|
||||
payload, err := encodeBody(raw)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if SignBody(payload, apiKey) != sign {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
delete(data, "sign")
|
||||
return data, true
|
||||
}
|
||||
@@ -14,6 +14,11 @@ type Product struct {
|
||||
CategoryID *int
|
||||
CategorySlug string
|
||||
IsActive bool
|
||||
AttrSize string
|
||||
AttrColor string
|
||||
AttrMaterial string
|
||||
AttrWeight string
|
||||
AttrBrand string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Images []ProductImage
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type ProductSpec struct {
|
||||
Label string
|
||||
Value string
|
||||
}
|
||||
|
||||
func (p Product) Specs() []ProductSpec {
|
||||
var specs []ProductSpec
|
||||
if p.AttrBrand != "" {
|
||||
specs = append(specs, ProductSpec{"Бренд", p.AttrBrand})
|
||||
}
|
||||
if p.AttrSize != "" {
|
||||
specs = append(specs, ProductSpec{"Размер", p.AttrSize})
|
||||
}
|
||||
if p.AttrColor != "" {
|
||||
specs = append(specs, ProductSpec{"Цвет", p.AttrColor})
|
||||
}
|
||||
if p.AttrMaterial != "" {
|
||||
specs = append(specs, ProductSpec{"Материал", p.AttrMaterial})
|
||||
}
|
||||
if p.AttrWeight != "" {
|
||||
specs = append(specs, ProductSpec{"Вес", p.AttrWeight})
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
type PriceHistoryEntry struct {
|
||||
ID int
|
||||
ProductID int
|
||||
Price float64
|
||||
SalePrice float64
|
||||
RecordedAt time.Time
|
||||
}
|
||||
|
||||
type Reservation struct {
|
||||
ID int
|
||||
ProductID int
|
||||
SessionKey string
|
||||
UserID *int
|
||||
CustomerName string
|
||||
CustomerPhone string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
+19
-10
@@ -28,16 +28,25 @@ type CartItem struct {
|
||||
}
|
||||
|
||||
type Order struct {
|
||||
ID int
|
||||
UserID *int
|
||||
GuestName string
|
||||
GuestEmail string
|
||||
GuestPhone string
|
||||
Address string
|
||||
Total float64
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
Items []OrderItem
|
||||
ID int
|
||||
UserID *int
|
||||
GuestName string
|
||||
GuestEmail string
|
||||
GuestPhone string
|
||||
Address string
|
||||
Total float64
|
||||
Status string
|
||||
PaymentMethod string
|
||||
HeleketUUID string
|
||||
HeleketOrderID string
|
||||
HeleketPaymentURL string
|
||||
HeleketPaymentStatus string
|
||||
CreatedAt time.Time
|
||||
Items []OrderItem
|
||||
}
|
||||
|
||||
func (o Order) IsHeleket() bool {
|
||||
return o.PaymentMethod == "heleket"
|
||||
}
|
||||
|
||||
type OrderItem struct {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package orderstatus
|
||||
|
||||
type Info struct {
|
||||
Icon string
|
||||
Label string
|
||||
Class string
|
||||
Step int
|
||||
@@ -8,17 +9,17 @@ type Info struct {
|
||||
}
|
||||
|
||||
var statuses = map[string]Info{
|
||||
"pending": {Label: "В ожидании", Class: "status--pending", Step: 1},
|
||||
"unpaid": {Label: "Не оплачено", Class: "status--unpaid", Step: 1},
|
||||
"paid": {Label: "Оплачено", Class: "status--paid", Step: 2},
|
||||
"processing": {Label: "В обработке", Class: "status--processing", Step: 3},
|
||||
"shipped": {Label: "Отправлен", Class: "status--shipped", Step: 4},
|
||||
"delivered": {Label: "Доставлен", Class: "status--delivered", Step: 5},
|
||||
"cancelled": {Label: "Отменён", Class: "status--cancelled", Step: 0, Terminal: true},
|
||||
"refunded": {Label: "Возврат", Class: "status--refunded", Step: 0, Terminal: true},
|
||||
"pending": {Icon: "⏳", Label: "В ожидании", Class: "status--pending", Step: 1},
|
||||
"unpaid": {Icon: "💳", Label: "Не оплачено", Class: "status--unpaid", Step: 1},
|
||||
"paid": {Icon: "✓", Label: "Оплачено", Class: "status--paid", Step: 2},
|
||||
"processing": {Icon: "⚙", Label: "В обработке", Class: "status--processing", Step: 3},
|
||||
"shipped": {Icon: "📦", Label: "Отправлен", Class: "status--shipped", Step: 4},
|
||||
"delivered": {Icon: "✓", Label: "Доставлен", Class: "status--delivered", Step: 5},
|
||||
"cancelled": {Icon: "✕", Label: "Отменён", Class: "status--cancelled", Step: 0, Terminal: true},
|
||||
"refunded": {Icon: "↩", Label: "Возврат", Class: "status--refunded", Step: 0, Terminal: true},
|
||||
// совместимость со старыми заказами
|
||||
"new": {Label: "В ожидании", Class: "status--pending", Step: 1},
|
||||
"confirmed": {Label: "Оплачено", Class: "status--paid", Step: 2},
|
||||
"new": {Icon: "⏳", Label: "В ожидании", Class: "status--pending", Step: 1},
|
||||
"confirmed": {Icon: "✓", Label: "Оплачено", Class: "status--paid", Step: 2},
|
||||
}
|
||||
|
||||
// Timeline — основной путь доставки (для таймлайна в кабинете)
|
||||
@@ -47,8 +48,9 @@ func Get(code string) Info {
|
||||
return Info{Label: code, Class: "status--pending", Step: 1}
|
||||
}
|
||||
|
||||
func Label(code string) string { return Get(code).Label }
|
||||
func Class(code string) string { return Get(code).Class }
|
||||
func Label(code string) string { return Get(code).Label }
|
||||
func Icon(code string) string { return Get(code).Icon }
|
||||
func Class(code string) string { return Get(code).Class }
|
||||
func Step(code string) int { return Get(code).Step }
|
||||
func IsTerminal(code string) bool { return Get(code).Terminal }
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ import (
|
||||
"shop/internal/models"
|
||||
)
|
||||
|
||||
const orderSelect = `
|
||||
SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status,
|
||||
COALESCE(payment_method, 'cod'), COALESCE(heleket_uuid, ''), COALESCE(heleket_order_id, ''),
|
||||
COALESCE(heleket_payment_url, ''), COALESCE(heleket_payment_status, ''), created_at
|
||||
FROM orders
|
||||
`
|
||||
|
||||
type OrderRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
@@ -17,6 +24,15 @@ func NewOrderRepository(pool *pgxpool.Pool) *OrderRepository {
|
||||
return &OrderRepository{pool: pool}
|
||||
}
|
||||
|
||||
func scanOrder(row pgx.Row) (models.Order, error) {
|
||||
var o models.Order
|
||||
err := row.Scan(
|
||||
&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status,
|
||||
&o.PaymentMethod, &o.HeleketUUID, &o.HeleketOrderID, &o.HeleketPaymentURL, &o.HeleketPaymentStatus, &o.CreatedAt,
|
||||
)
|
||||
return o, err
|
||||
}
|
||||
|
||||
func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items []models.CartItem) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -24,11 +40,20 @@ func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
status := order.Status
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
paymentMethod := order.PaymentMethod
|
||||
if paymentMethod == "" {
|
||||
paymentMethod = "cod"
|
||||
}
|
||||
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'pending')
|
||||
INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status, payment_method)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, created_at
|
||||
`, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total,
|
||||
`, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total, status, paymentMethod,
|
||||
).Scan(&order.ID, &order.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -47,11 +72,37 @@ func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *OrderRepository) UpdateHeleketPayment(ctx context.Context, orderID int, uuid, externalID, paymentURL, paymentStatus string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE orders SET
|
||||
heleket_uuid = $1,
|
||||
heleket_order_id = $2,
|
||||
heleket_payment_url = $3,
|
||||
heleket_payment_status = $4
|
||||
WHERE id = $5
|
||||
`, uuid, externalID, paymentURL, paymentStatus, orderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *OrderRepository) UpdateHeleketStatus(ctx context.Context, orderID int, paymentStatus, orderStatus string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE orders SET heleket_payment_status = $1, status = $2 WHERE id = $3
|
||||
`, paymentStatus, orderStatus, orderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *OrderRepository) GetByHeleketOrderID(ctx context.Context, externalID string) (models.Order, error) {
|
||||
row := r.pool.QueryRow(ctx, orderSelect+` WHERE heleket_order_id = $1`, externalID)
|
||||
o, err := scanOrder(row)
|
||||
if err != nil {
|
||||
return models.Order{}, err
|
||||
}
|
||||
o.Items, err = r.items(ctx, o.ID)
|
||||
return o, err
|
||||
}
|
||||
|
||||
func (r *OrderRepository) ByUser(ctx context.Context, userID int) ([]models.Order, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at
|
||||
FROM orders WHERE user_id = $1 ORDER BY created_at DESC
|
||||
`, userID)
|
||||
rows, err := r.pool.Query(ctx, orderSelect+` WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -59,8 +110,8 @@ func (r *OrderRepository) ByUser(ctx context.Context, userID int) ([]models.Orde
|
||||
|
||||
var orders []models.Order
|
||||
for rows.Next() {
|
||||
var o models.Order
|
||||
if err := rows.Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt); err != nil {
|
||||
o, err := scanOrder(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.Items, err = r.items(ctx, o.ID)
|
||||
@@ -94,10 +145,7 @@ func (r *OrderRepository) items(ctx context.Context, orderID int) ([]models.Orde
|
||||
}
|
||||
|
||||
func (r *OrderRepository) All(ctx context.Context, limit int) ([]models.Order, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at
|
||||
FROM orders ORDER BY created_at DESC LIMIT $1
|
||||
`, limit)
|
||||
rows, err := r.pool.Query(ctx, orderSelect+` ORDER BY created_at DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -106,11 +154,8 @@ func (r *OrderRepository) All(ctx context.Context, limit int) ([]models.Order, e
|
||||
}
|
||||
|
||||
func (r *OrderRepository) GetByID(ctx context.Context, id int) (models.Order, error) {
|
||||
var o models.Order
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at
|
||||
FROM orders WHERE id = $1
|
||||
`, id).Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt)
|
||||
row := r.pool.QueryRow(ctx, orderSelect+` WHERE id = $1`, id)
|
||||
o, err := scanOrder(row)
|
||||
if err != nil {
|
||||
return models.Order{}, err
|
||||
}
|
||||
@@ -138,11 +183,10 @@ func (r *OrderRepository) Count(ctx context.Context) (int, error) {
|
||||
func (r *OrderRepository) collectOrders(ctx context.Context, rows pgx.Rows) ([]models.Order, error) {
|
||||
var orders []models.Order
|
||||
for rows.Next() {
|
||||
var o models.Order
|
||||
if err := rows.Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt); err != nil {
|
||||
o, err := scanOrder(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var err error
|
||||
o.Items, err = r.items(ctx, o.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shop/internal/models"
|
||||
)
|
||||
|
||||
type PriceHistoryRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPriceHistoryRepository(pool *pgxpool.Pool) *PriceHistoryRepository {
|
||||
return &PriceHistoryRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *PriceHistoryRepository) Record(ctx context.Context, productID int, price, salePrice float64) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO product_price_history (product_id, price, sale_price)
|
||||
VALUES ($1, $2, NULLIF($3, 0))
|
||||
`, productID, price, salePrice)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PriceHistoryRepository) ByProduct(ctx context.Context, productID int, limit int) ([]models.PriceHistoryEntry, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, product_id, price, COALESCE(sale_price, 0), recorded_at
|
||||
FROM product_price_history
|
||||
WHERE product_id = $1
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT $2
|
||||
`, productID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []models.PriceHistoryEntry
|
||||
for rows.Next() {
|
||||
var e models.PriceHistoryEntry
|
||||
if err := rows.Scan(&e.ID, &e.ProductID, &e.Price, &e.SalePrice, &e.RecordedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
@@ -22,7 +22,10 @@ func NewProductRepository(pool *pgxpool.Pool) *ProductRepository {
|
||||
const productSelect = `
|
||||
SELECT p.id, p.name, p.description, p.details, p.price, COALESCE(p.sale_price, 0),
|
||||
p.image_url, COALESCE(c.name, p.category, 'Разное'), p.category_id,
|
||||
COALESCE(c.slug, ''), p.is_active, p.created_at, p.updated_at
|
||||
COALESCE(c.slug, ''), p.is_active,
|
||||
COALESCE(p.attr_size, ''), COALESCE(p.attr_color, ''), COALESCE(p.attr_material, ''),
|
||||
COALESCE(p.attr_weight, ''), COALESCE(p.attr_brand, ''),
|
||||
p.created_at, p.updated_at
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
`
|
||||
@@ -32,7 +35,9 @@ func (r *ProductRepository) scanProduct(row pgx.Row) (models.Product, error) {
|
||||
var catID sql.NullInt64
|
||||
err := row.Scan(
|
||||
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
|
||||
&p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
|
||||
&p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive,
|
||||
&p.AttrSize, &p.AttrColor, &p.AttrMaterial, &p.AttrWeight, &p.AttrBrand,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
)
|
||||
if catID.Valid {
|
||||
id := int(catID.Int64)
|
||||
@@ -94,10 +99,12 @@ func (r *ProductRepository) GetActiveByID(ctx context.Context, id int) (models.P
|
||||
|
||||
func (r *ProductRepository) Create(ctx context.Context, p *models.Product) error {
|
||||
return r.pool.QueryRow(ctx, `
|
||||
INSERT INTO products (name, description, details, price, sale_price, image_url, category, category_id, is_active)
|
||||
VALUES ($1, $2, $3, $4, NULLIF($5, 0), $6, $7, $8, $9)
|
||||
INSERT INTO products (name, description, details, price, sale_price, image_url, category, category_id, is_active,
|
||||
attr_size, attr_color, attr_material, attr_weight, attr_brand)
|
||||
VALUES ($1, $2, $3, $4, NULLIF($5, 0), $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, created_at, updated_at
|
||||
`, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive,
|
||||
p.AttrSize, p.AttrColor, p.AttrMaterial, p.AttrWeight, p.AttrBrand,
|
||||
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
|
||||
}
|
||||
|
||||
@@ -105,9 +112,11 @@ func (r *ProductRepository) Update(ctx context.Context, p *models.Product) error
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE products SET name = $1, description = $2, details = $3, price = $4,
|
||||
sale_price = NULLIF($5, 0), image_url = $6, category = $7, category_id = $8,
|
||||
is_active = $9, updated_at = NOW()
|
||||
WHERE id = $10
|
||||
`, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive, p.ID)
|
||||
is_active = $9, attr_size = $10, attr_color = $11, attr_material = $12,
|
||||
attr_weight = $13, attr_brand = $14, updated_at = NOW()
|
||||
WHERE id = $15
|
||||
`, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive,
|
||||
p.AttrSize, p.AttrColor, p.AttrMaterial, p.AttrWeight, p.AttrBrand, p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -200,7 +209,9 @@ func (r *ProductRepository) collectProducts(rows pgx.Rows) ([]models.Product, er
|
||||
var catID sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice,
|
||||
&p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
|
||||
&p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive,
|
||||
&p.AttrSize, &p.AttrColor, &p.AttrMaterial, &p.AttrWeight, &p.AttrBrand,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shop/internal/models"
|
||||
)
|
||||
|
||||
type ReservationRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewReservationRepository(pool *pgxpool.Pool) *ReservationRepository {
|
||||
return &ReservationRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *ReservationRepository) Create(ctx context.Context, res *models.Reservation) error {
|
||||
return r.pool.QueryRow(ctx, `
|
||||
INSERT INTO product_reservations (product_id, session_key, user_id, customer_name, customer_phone, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at
|
||||
`, res.ProductID, res.SessionKey, res.UserID, res.CustomerName, res.CustomerPhone, res.ExpiresAt,
|
||||
).Scan(&res.ID, &res.CreatedAt)
|
||||
}
|
||||
|
||||
func (r *ReservationRepository) ActiveByProduct(ctx context.Context, productID int) (*models.Reservation, error) {
|
||||
var res models.Reservation
|
||||
var userID sql.NullInt64
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, product_id, session_key, user_id, customer_name, customer_phone, expires_at, created_at
|
||||
FROM product_reservations
|
||||
WHERE product_id = $1 AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, productID).Scan(
|
||||
&res.ID, &res.ProductID, &res.SessionKey, &userID,
|
||||
&res.CustomerName, &res.CustomerPhone, &res.ExpiresAt, &res.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID.Valid {
|
||||
id := int(userID.Int64)
|
||||
res.UserID = &id
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (r *ReservationRepository) ReplaceForProduct(ctx context.Context, res *models.Reservation) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
_, err = tx.Exec(ctx, `DELETE FROM product_reservations WHERE product_id = $1`, res.ProductID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO product_reservations (product_id, session_key, user_id, customer_name, customer_phone, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at
|
||||
`, res.ProductID, res.SessionKey, res.UserID, res.CustomerName, res.CustomerPhone, res.ExpiresAt,
|
||||
).Scan(&res.ID, &res.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *ReservationRepository) ExpiresIn() time.Duration {
|
||||
return 24 * time.Hour
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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);
|
||||
@@ -0,0 +1,5 @@
|
||||
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 '';
|
||||
Reference in New Issue
Block a user