almost all ready
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
appmiddleware "git.ma-al.com/goc_marek/ps_shop/internal/http/middleware"
|
||||
pscart "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cart"
|
||||
pscookie "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cookie"
|
||||
)
|
||||
|
||||
type cartSessionService interface {
|
||||
RefreshExpiry(ctx context.Context, session *pscookie.SessionContext) error
|
||||
ResolveCookiePath(ctx context.Context, req *http.Request) (string, error)
|
||||
}
|
||||
|
||||
type CartHandler struct {
|
||||
carts *pscart.Service
|
||||
codec pscookie.Codec
|
||||
sessions cartSessionService
|
||||
}
|
||||
|
||||
func NewCartHandler(carts *pscart.Service, codec pscookie.Codec, sessions cartSessionService) *CartHandler {
|
||||
return &CartHandler{
|
||||
carts: carts,
|
||||
codec: codec,
|
||||
sessions: sessions,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CartHandler) Handle(c echo.Context) error {
|
||||
if h == nil || h.carts == nil || h.codec == nil || h.sessions == nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "cart handler is not initialized")
|
||||
}
|
||||
|
||||
session := appmiddleware.GetSession(c)
|
||||
action := cartActionFromRequest(c)
|
||||
|
||||
input, err := cartMutationInput(c, session)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
var result *pscart.MutationResult
|
||||
switch action {
|
||||
case cartActionDelete:
|
||||
result, err = h.carts.DeleteProduct(c.Request().Context(), input)
|
||||
case cartActionUpdate:
|
||||
result, err = h.carts.UpdateProduct(c.Request().Context(), input)
|
||||
default:
|
||||
result, err = h.carts.AddProduct(c.Request().Context(), input)
|
||||
}
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "cart mutation failed: "+err.Error())
|
||||
}
|
||||
|
||||
syncSessionCartID(session, result.CartID)
|
||||
if err := h.writeSessionCookie(c, session); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "cart cookie update failed: "+err.Error())
|
||||
}
|
||||
|
||||
if wantsJSON(c.Request()) {
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
return c.Redirect(http.StatusSeeOther, cartRedirectTarget(c.Request()))
|
||||
}
|
||||
|
||||
type cartAction string
|
||||
|
||||
const (
|
||||
cartActionAdd cartAction = "add"
|
||||
cartActionUpdate cartAction = "update"
|
||||
cartActionDelete cartAction = "delete"
|
||||
)
|
||||
|
||||
func cartActionFromRequest(c echo.Context) cartAction {
|
||||
switch c.Request().Method {
|
||||
case http.MethodDelete:
|
||||
return cartActionDelete
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
return cartActionUpdate
|
||||
}
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(c.FormValue("action")))
|
||||
switch action {
|
||||
case string(cartActionDelete):
|
||||
return cartActionDelete
|
||||
case string(cartActionUpdate):
|
||||
return cartActionUpdate
|
||||
}
|
||||
|
||||
switch {
|
||||
case c.FormValue("delete") != "":
|
||||
return cartActionDelete
|
||||
case c.FormValue("update") != "":
|
||||
return cartActionUpdate
|
||||
default:
|
||||
return cartActionAdd
|
||||
}
|
||||
}
|
||||
|
||||
func cartMutationInput(c echo.Context, session *pscookie.SessionContext) (pscart.MutationInput, error) {
|
||||
productID, err := formInt64(c, "id_product")
|
||||
if err != nil || productID == 0 {
|
||||
return pscart.MutationInput{}, fmt.Errorf("valid id_product is required")
|
||||
}
|
||||
|
||||
quantity := int64(1)
|
||||
if raw := strings.TrimSpace(c.FormValue("qty")); raw != "" {
|
||||
quantity, err = strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || quantity < 0 {
|
||||
return pscart.MutationInput{}, fmt.Errorf("valid qty is required")
|
||||
}
|
||||
}
|
||||
|
||||
productAttributeID, err := firstFormInt64(c, "id_product_attribute", "ipa")
|
||||
if err != nil {
|
||||
return pscart.MutationInput{}, fmt.Errorf("valid id_product_attribute is required")
|
||||
}
|
||||
customizationID, err := firstFormInt64(c, "id_customization", "customization_id")
|
||||
if err != nil {
|
||||
return pscart.MutationInput{}, fmt.Errorf("valid id_customization is required")
|
||||
}
|
||||
|
||||
return pscart.MutationInput{
|
||||
CartID: int64Value(session.CartID),
|
||||
ProductID: productID,
|
||||
ProductAttributeID: productAttributeID,
|
||||
CustomizationID: customizationID,
|
||||
Quantity: quantity,
|
||||
CustomerID: int64Value(session.CustomerID),
|
||||
GuestID: int64Value(session.GuestID),
|
||||
LanguageID: int64Value(session.LanguageID),
|
||||
CurrencyID: int64Value(session.CurrencyID),
|
||||
ShopID: int64Value(session.ShopID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formInt64(c echo.Context, key string) (int64, error) {
|
||||
raw := strings.TrimSpace(c.FormValue(key))
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
return strconv.ParseInt(raw, 10, 64)
|
||||
}
|
||||
|
||||
func firstFormInt64(c echo.Context, keys ...string) (int64, error) {
|
||||
for _, key := range keys {
|
||||
value, err := formInt64(c, key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if value != 0 {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func syncSessionCartID(session *pscookie.SessionContext, cartID int64) {
|
||||
if session == nil || cartID == 0 {
|
||||
return
|
||||
}
|
||||
if session.Values == nil {
|
||||
session.Values = map[string]string{}
|
||||
}
|
||||
|
||||
session.CartID = int64Ptr(cartID)
|
||||
session.Values["id_cart"] = strconv.FormatInt(cartID, 10)
|
||||
session.OrderedKeys = appendOrderedKeyIfMissing(session.OrderedKeys, "id_cart")
|
||||
session.OrderedKeys = moveOrderedKeyToEnd(session.OrderedKeys, "checksum")
|
||||
session.Plaintext = ""
|
||||
session.RawCookie = ""
|
||||
}
|
||||
|
||||
func appendOrderedKeyIfMissing(keys []string, key string) []string {
|
||||
for _, existing := range keys {
|
||||
if existing == key {
|
||||
return keys
|
||||
}
|
||||
}
|
||||
return append(keys, key)
|
||||
}
|
||||
|
||||
func moveOrderedKeyToEnd(keys []string, key string) []string {
|
||||
for i, existing := range keys {
|
||||
if existing == key {
|
||||
keys = append(keys[:i], keys[i+1:]...)
|
||||
return append(keys, key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func int64Ptr(value int64) *int64 {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
v := value
|
||||
return &v
|
||||
}
|
||||
|
||||
func int64Value(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func (h *CartHandler) writeSessionCookie(c echo.Context, session *pscookie.SessionContext) error {
|
||||
if err := h.sessions.RefreshExpiry(c.Request().Context(), session); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cookiePath, err := h.sessions.ResolveCookiePath(c.Request().Context(), c.Request())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encoded, err := h.codec.Encode(session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.RawCookie = encoded
|
||||
writeSessionCookie(c.Request(), c.Response(), session, session.CookieName, encoded, cookiePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSessionCookie(req *http.Request, res *echo.Response, session *pscookie.SessionContext, name, value, path string) {
|
||||
maxAge := 1
|
||||
if session != nil && session.ExpiresAt != nil {
|
||||
maxAge = int(session.ExpiresAt.UTC().Unix())
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
path = "/"
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("%s=%s; path=%s; max-age=%d; HttpOnly; SameSite=Lax", name, value, path, maxAge)
|
||||
if requestCookieSecure(req) {
|
||||
header += "; Secure"
|
||||
}
|
||||
res.Header().Add(echo.HeaderSetCookie, header)
|
||||
}
|
||||
|
||||
func requestCookieSecure(req *http.Request) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
if req.TLS != nil {
|
||||
return true
|
||||
}
|
||||
forwarded := req.Header.Get("X-Forwarded-Proto")
|
||||
if strings.Contains(forwarded, ",") {
|
||||
forwarded = strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||
}
|
||||
return strings.EqualFold(forwarded, "https")
|
||||
}
|
||||
|
||||
func wantsJSON(req *http.Request) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(req.Header.Get(echo.HeaderAccept)), "application/json")
|
||||
}
|
||||
|
||||
func cartRedirectTarget(req *http.Request) string {
|
||||
if req == nil {
|
||||
return "/cart?action=show"
|
||||
}
|
||||
referer := strings.TrimSpace(req.Referer())
|
||||
if referer != "" {
|
||||
return referer
|
||||
}
|
||||
path := requestLanguagePrefix(req) + "/cart"
|
||||
if path == "/cart" {
|
||||
return "/cart?action=show"
|
||||
}
|
||||
return path + "?action=show"
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"gorm.io/gorm"
|
||||
|
||||
appmiddleware "git.ma-al.com/goc_marek/ps_shop/internal/http/middleware"
|
||||
pscart "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cart"
|
||||
pscatalog "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/catalog"
|
||||
psconfig "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/config"
|
||||
pscustomer "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/customer"
|
||||
psroutes "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/routes"
|
||||
"git.ma-al.com/goc_marek/ps_shop/internal/render"
|
||||
"git.ma-al.com/goc_marek/ps_shop/internal/viewmodel"
|
||||
)
|
||||
|
||||
type CartPageHandler struct {
|
||||
catalog *pscatalog.Service
|
||||
customers *pscustomer.Service
|
||||
carts *pscart.Service
|
||||
renderer *render.Engine
|
||||
config psconfig.Config
|
||||
products *psroutes.ProductRoute
|
||||
categories *psroutes.CategoryRoute
|
||||
}
|
||||
|
||||
func NewCartPageHandler(catalog *pscatalog.Service, customers *pscustomer.Service, carts *pscart.Service, renderer *render.Engine, cfg psconfig.Config, products *psroutes.ProductRoute, categories *psroutes.CategoryRoute) *CartPageHandler {
|
||||
return &CartPageHandler{
|
||||
catalog: catalog,
|
||||
customers: customers,
|
||||
carts: carts,
|
||||
renderer: renderer,
|
||||
config: cfg,
|
||||
products: products,
|
||||
categories: categories,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CartPageHandler) Show(c echo.Context) error {
|
||||
session := appmiddleware.GetSession(c)
|
||||
if h == nil || h.catalog == nil || h.carts == nil || h.renderer == nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "cart page handler is not initialized")
|
||||
}
|
||||
|
||||
languageID := int64Default(session.LanguageID, 1)
|
||||
languageID = h.catalog.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
||||
shopID := int64Default(session.ShopID, 1)
|
||||
currencyID := int64Default(session.CurrencyID, 1)
|
||||
|
||||
var profile *pscustomer.Profile
|
||||
var err error
|
||||
if session.CustomerID != nil && h.customers != nil {
|
||||
profile, err = h.customers.GetByID(c.Request().Context(), *session.CustomerID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "customer query failed: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
cartPage := pscart.Page{}
|
||||
cartSummary := &pscart.Summary{}
|
||||
if session.CartID != nil {
|
||||
cartPage, cartSummary, err = h.loadCart(c.Request().Context(), *session.CartID, languageID, shopID, currencyID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "cart query failed: "+err.Error())
|
||||
}
|
||||
assignCartProductLinks(c.Request(), h.products, &cartPage)
|
||||
assignCartProductImages(requestBaseURL(c.Request()), &cartPage)
|
||||
}
|
||||
|
||||
page := viewmodel.CartPageData{
|
||||
Cart: cartPage,
|
||||
Session: session,
|
||||
Customer: profile,
|
||||
CartSummary: cartSummary,
|
||||
ShopBaseURL: h.config.PrestaShopBaseURL,
|
||||
}
|
||||
menu, err := loadMenu(c.Request(), h.catalog, h.categories, languageID, shopID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "menu query failed: "+err.Error())
|
||||
}
|
||||
page.Menu = menu
|
||||
locale, err := loadHeaderLocale(c.Request(), h.catalog, session, languageID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "locale query failed: "+err.Error())
|
||||
}
|
||||
page.Locale = locale
|
||||
|
||||
return h.renderer.Cart(c.Response(), c.Request(), page)
|
||||
}
|
||||
|
||||
func (h *CartPageHandler) loadCart(ctx context.Context, cartID, languageID, shopID, currencyID int64) (pscart.Page, *pscart.Summary, error) {
|
||||
page, err := h.carts.PageByID(ctx, cartID, languageID, shopID, currencyID)
|
||||
if err != nil {
|
||||
return pscart.Page{}, nil, err
|
||||
}
|
||||
summary := &pscart.Summary{
|
||||
ID: page.ID,
|
||||
TotalItems: page.TotalItems,
|
||||
}
|
||||
return *page, summary, nil
|
||||
}
|
||||
|
||||
func assignCartProductLinks(req *http.Request, route *psroutes.ProductRoute, page *pscart.Page) {
|
||||
if page == nil || route == nil {
|
||||
return
|
||||
}
|
||||
langPrefix := requestLanguagePrefix(req)
|
||||
for i := range page.Items {
|
||||
product := &page.Items[i]
|
||||
product.URL = route.BuildPath(psroutes.ProductURLData{
|
||||
ID: product.ProductID,
|
||||
Slug: product.Slug,
|
||||
CategoryPath: product.CategoryPath,
|
||||
EAN13: product.EAN13,
|
||||
LanguagePrefix: langPrefix,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assignCartProductImages(baseURL string, page *pscart.Page) {
|
||||
if page == nil {
|
||||
return
|
||||
}
|
||||
for i := range page.Items {
|
||||
page.Items[i].ImageURL = prestashopImageURL(baseURL, page.Items[i].CoverImageID, "home_default")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
pscookie "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cookie"
|
||||
)
|
||||
|
||||
func TestCartActionFromRequestDefaultsToAdd(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/cart", strings.NewReader(url.Values{
|
||||
"id_product": []string{"42"},
|
||||
}.Encode()))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
if got := cartActionFromRequest(c); got != cartActionAdd {
|
||||
t.Fatalf("cartActionFromRequest() = %q, want %q", got, cartActionAdd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCartActionFromRequestHonorsDeleteFlag(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/cart", strings.NewReader(url.Values{
|
||||
"delete": []string{"1"},
|
||||
"id_product": []string{"42"},
|
||||
}.Encode()))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
if got := cartActionFromRequest(c); got != cartActionDelete {
|
||||
t.Fatalf("cartActionFromRequest() = %q, want %q", got, cartActionDelete)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSessionCartIDPreservesChecksumOrder(t *testing.T) {
|
||||
session := &pscookie.SessionContext{
|
||||
Values: map[string]string{
|
||||
"id_lang": "5",
|
||||
"checksum": "123",
|
||||
"id_guest": "11",
|
||||
"id_cart": "",
|
||||
"id_shop": "1",
|
||||
"id_guest2": "ignored",
|
||||
},
|
||||
OrderedKeys: []string{"id_lang", "id_guest", "checksum"},
|
||||
RawCookie: "raw",
|
||||
Plaintext: "plaintext",
|
||||
}
|
||||
|
||||
syncSessionCartID(session, 99)
|
||||
|
||||
if session.CartID == nil || *session.CartID != 99 {
|
||||
t.Fatalf("CartID = %v, want 99", session.CartID)
|
||||
}
|
||||
if got := session.Values["id_cart"]; got != "99" {
|
||||
t.Fatalf("Values[id_cart] = %q, want %q", got, "99")
|
||||
}
|
||||
wantOrder := []string{"id_lang", "id_guest", "id_cart", "checksum"}
|
||||
if len(session.OrderedKeys) != len(wantOrder) {
|
||||
t.Fatalf("OrderedKeys length = %d, want %d", len(session.OrderedKeys), len(wantOrder))
|
||||
}
|
||||
for i, want := range wantOrder {
|
||||
if got := session.OrderedKeys[i]; got != want {
|
||||
t.Fatalf("OrderedKeys[%d] = %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
if session.RawCookie != "" {
|
||||
t.Fatalf("RawCookie = %q, want empty", session.RawCookie)
|
||||
}
|
||||
if session.Plaintext != "" {
|
||||
t.Fatalf("Plaintext = %q, want empty", session.Plaintext)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package handlers
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -52,12 +54,16 @@ func (h *CategoryHandler) Show(c echo.Context) error {
|
||||
languageID := int64Default(session.LanguageID, 1)
|
||||
languageID = h.catalog.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
||||
shopID := int64Default(session.ShopID, 1)
|
||||
currencyID := int64Default(session.CurrencyID, 1)
|
||||
|
||||
category, err := h.catalog.GetCategoryPage(c.Request().Context(), pscatalog.CategoryPageRequest{
|
||||
ID: categoryID(c),
|
||||
Slug: categorySlug(c),
|
||||
LanguageID: languageID,
|
||||
ShopID: shopID,
|
||||
CurrencyID: currencyID,
|
||||
Page: categoryPageParam(c.Request()),
|
||||
PerPage: 30,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -89,7 +95,9 @@ func (h *CategoryHandler) Show(c echo.Context) error {
|
||||
CartSummary: cartSummary,
|
||||
ShopBaseURL: h.config.PrestaShopBaseURL,
|
||||
}
|
||||
page.Pagination = categoryPaginationView(c.Request(), category.Pagination)
|
||||
assignCategoryProductLinks(c.Request(), h.products, &page)
|
||||
assignCategoryProductImages(requestBaseURL(c.Request()), &page)
|
||||
menu, err := loadMenu(c.Request(), h.catalog, h.categories, languageID, shopID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "menu query failed: "+err.Error())
|
||||
@@ -137,10 +145,27 @@ func assignCategoryProductLinks(req *http.Request, route *psroutes.ProductRoute,
|
||||
if page == nil {
|
||||
return
|
||||
}
|
||||
assignProductCardsLinks(req, route, page.Category.Products, page.Category.Slug)
|
||||
}
|
||||
|
||||
func assignCategoryProductImages(baseURL string, page *viewmodel.CategoryPageData) {
|
||||
if page == nil {
|
||||
return
|
||||
}
|
||||
assignProductCardsImages(baseURL, page.Category.Products)
|
||||
}
|
||||
|
||||
func assignProductCardsLinks(req *http.Request, route *psroutes.ProductRoute, products []pscatalog.CategoryProductCard, fallbackCategoryPath string) {
|
||||
if route == nil {
|
||||
return
|
||||
}
|
||||
langPrefix := requestLanguagePrefix(req)
|
||||
categoryPath := page.Category.Slug
|
||||
for i := range page.Category.Products {
|
||||
product := &page.Category.Products[i]
|
||||
for i := range products {
|
||||
product := &products[i]
|
||||
categoryPath := strings.TrimSpace(product.CategorySlug)
|
||||
if categoryPath == "" {
|
||||
categoryPath = fallbackCategoryPath
|
||||
}
|
||||
product.URL = route.BuildPath(psroutes.ProductURLData{
|
||||
ID: product.ID,
|
||||
Slug: product.Slug,
|
||||
@@ -150,3 +175,65 @@ func assignCategoryProductLinks(req *http.Request, route *psroutes.ProductRoute,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assignProductCardsImages(baseURL string, products []pscatalog.CategoryProductCard) {
|
||||
for i := range products {
|
||||
products[i].ImageURL = prestashopImageURL(baseURL, products[i].CoverImageID, "home_default")
|
||||
}
|
||||
}
|
||||
|
||||
func categoryPageParam(req *http.Request) int {
|
||||
if req == nil || req.URL == nil {
|
||||
return 1
|
||||
}
|
||||
raw := strings.TrimSpace(req.URL.Query().Get("page"))
|
||||
if raw == "" {
|
||||
return 1
|
||||
}
|
||||
page, err := strconv.Atoi(raw)
|
||||
if err != nil || page <= 0 {
|
||||
return 1
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func categoryPageURL(req *http.Request, page int) string {
|
||||
if req == nil || req.URL == nil || page <= 0 {
|
||||
return ""
|
||||
}
|
||||
query := url.Values{}
|
||||
for key, values := range req.URL.Query() {
|
||||
for _, value := range values {
|
||||
query.Add(key, value)
|
||||
}
|
||||
}
|
||||
if page <= 1 {
|
||||
query.Del("page")
|
||||
} else {
|
||||
query.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
path := req.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
if encoded := query.Encode(); encoded != "" {
|
||||
return path + "?" + encoded
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func categoryPaginationView(req *http.Request, meta pscatalog.CategoryPagination) viewmodel.CategoryPagination {
|
||||
view := viewmodel.CategoryPagination{
|
||||
Page: meta.Page,
|
||||
PerPage: meta.PerPage,
|
||||
TotalItems: meta.TotalItems,
|
||||
TotalPages: meta.TotalPages,
|
||||
}
|
||||
if meta.Page > 1 {
|
||||
view.PrevURL = categoryPageURL(req, meta.Page-1)
|
||||
}
|
||||
if meta.TotalPages > 0 && meta.Page < meta.TotalPages {
|
||||
view.NextURL = categoryPageURL(req, meta.Page+1)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
@@ -14,17 +14,31 @@ import (
|
||||
|
||||
const (
|
||||
testCookieKey = "def000008bf3d70e7012b7493c382d561e193218d0c74ab162fb0ea8029ce20e926531b4bcf0aaec9381152e6c161f198e06918b2d1aad67cc7cf40819a51ee328c63830"
|
||||
testCookie = "def5020099dce5cd9ecf197adb5532a74e3db2ed9cba3d59b98f365353099b710bd562efa48b6bad1ad0a12b2ee54de0fbfcc6baa0545a8234141b03bfc1fbbbb9061af5011764b9c4dfd9c0ddcad767a453e0cc24d6b4a7c524e6c49aabd66ecc390e1a964b6e81a051b171051c829542facbb36cf64fcfebf069906dcc95476578be3fe59aaae466cf70bd9c877d301d908ec3aa4f55366567f460dfefac1684ce381293e8d4138382a42716d6aaecdcc7"
|
||||
)
|
||||
|
||||
func TestDecodeCookieFromQueryParameter(t *testing.T) {
|
||||
codec, err := pscookie.NewCodec(pscookie.Config{
|
||||
CookieName: "PrestaShop-test",
|
||||
CookieKey: testCookieKey,
|
||||
CookieIV: "vfRFMV42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodec() error = %v", err)
|
||||
}
|
||||
testCookie, err := codec.Encode(&pscookie.SessionContext{
|
||||
Values: map[string]string{
|
||||
"date_add": "2026-05-13 18:51:06",
|
||||
"id_lang": "1",
|
||||
"id_language": "1",
|
||||
"detect_language": "1",
|
||||
"id_currency": "1",
|
||||
"iso_code_country": "CZ",
|
||||
},
|
||||
OrderedKeys: []string{"date_add", "id_lang", "id_language", "detect_language", "iso_code_country", "id_currency", "checksum"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v", err)
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/debug/cookie/decode?value="+testCookie, nil)
|
||||
@@ -55,10 +69,25 @@ func TestDecodeCookieFromSession(t *testing.T) {
|
||||
codec, err := pscookie.NewCodec(pscookie.Config{
|
||||
CookieName: "PrestaShop-test",
|
||||
CookieKey: testCookieKey,
|
||||
CookieIV: "vfRFMV42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodec() error = %v", err)
|
||||
}
|
||||
testCookie, err := codec.Encode(&pscookie.SessionContext{
|
||||
Values: map[string]string{
|
||||
"date_add": "2026-05-13 18:51:06",
|
||||
"id_lang": "1",
|
||||
"id_language": "1",
|
||||
"detect_language": "1",
|
||||
"id_currency": "1",
|
||||
"iso_code_country": "CZ",
|
||||
},
|
||||
OrderedKeys: []string{"date_add", "id_lang", "id_language", "detect_language", "iso_code_country", "id_currency", "checksum"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v", err)
|
||||
}
|
||||
|
||||
session, err := codec.Decode(testCookie)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func prestashopImageURL(baseURL string, imageID sql.NullInt64, imageType string) string {
|
||||
if !imageID.Valid || imageID.Int64 == 0 {
|
||||
return ""
|
||||
}
|
||||
id := strconv.FormatInt(imageID.Int64, 10)
|
||||
var path strings.Builder
|
||||
path.Grow(len(baseURL) + len(id)*2 + len(imageType) + 16)
|
||||
path.WriteString(strings.TrimRight(baseURL, "/"))
|
||||
path.WriteString("/img/p")
|
||||
for _, ch := range id {
|
||||
path.WriteByte('/')
|
||||
path.WriteRune(ch)
|
||||
}
|
||||
path.WriteByte('/')
|
||||
path.WriteString(id)
|
||||
if strings.TrimSpace(imageType) != "" {
|
||||
path.WriteByte('-')
|
||||
path.WriteString(strings.TrimSpace(imageType))
|
||||
}
|
||||
path.WriteString(".webp")
|
||||
return path.String()
|
||||
}
|
||||
|
||||
func requestBaseURL(req *http.Request) string {
|
||||
if req == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if req.TLS != nil {
|
||||
scheme = "https"
|
||||
} else if forwarded := req.Header.Get("X-Forwarded-Proto"); forwarded != "" {
|
||||
if strings.Contains(forwarded, ",") {
|
||||
forwarded = strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||
}
|
||||
if strings.TrimSpace(forwarded) != "" {
|
||||
scheme = strings.TrimSpace(forwarded)
|
||||
}
|
||||
}
|
||||
|
||||
host := req.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = req.Host
|
||||
}
|
||||
if strings.Contains(host, ",") {
|
||||
host = strings.TrimSpace(strings.Split(host, ",")[0])
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return scheme + "://" + host
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -27,16 +28,18 @@ type ProductHandler struct {
|
||||
carts *pscart.Service
|
||||
renderer *render.Engine
|
||||
config psconfig.Config
|
||||
productURL *psroutes.ProductRoute
|
||||
categories *psroutes.CategoryRoute
|
||||
}
|
||||
|
||||
func NewProductHandler(products *pscatalog.Service, customers *pscustomer.Service, carts *pscart.Service, renderer *render.Engine, cfg psconfig.Config, categories *psroutes.CategoryRoute) *ProductHandler {
|
||||
func NewProductHandler(products *pscatalog.Service, customers *pscustomer.Service, carts *pscart.Service, renderer *render.Engine, cfg psconfig.Config, productURL *psroutes.ProductRoute, categories *psroutes.CategoryRoute) *ProductHandler {
|
||||
return &ProductHandler{
|
||||
products: products,
|
||||
customers: customers,
|
||||
carts: carts,
|
||||
renderer: renderer,
|
||||
config: cfg,
|
||||
productURL: productURL,
|
||||
categories: categories,
|
||||
}
|
||||
}
|
||||
@@ -50,12 +53,14 @@ func (h *ProductHandler) Show(c echo.Context) error {
|
||||
languageID := int64Default(session.LanguageID, 1)
|
||||
languageID = h.products.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
||||
shopID := int64Default(session.ShopID, 1)
|
||||
currencyID := int64Default(session.CurrencyID, 1)
|
||||
|
||||
product, err := h.products.GetProductPage(c.Request().Context(), pscatalog.ProductPageRequest{
|
||||
ID: productID(c),
|
||||
Slug: productSlug(c),
|
||||
LanguageID: languageID,
|
||||
ShopID: shopID,
|
||||
CurrencyID: currencyID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -63,6 +68,14 @@ func (h *ProductHandler) Show(c echo.Context) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
product.ImageURL = prestashopImageURL(requestBaseURL(c.Request()), product.CoverImageID, "large_default")
|
||||
assignProductGalleryImages(requestBaseURL(c.Request()), product)
|
||||
assignProductCombinationImages(requestBaseURL(c.Request()), product)
|
||||
if product.ImageURL == "" && len(product.GalleryImages) > 0 {
|
||||
product.ImageURL = product.GalleryImages[0].URL
|
||||
}
|
||||
assignProductCardsLinks(c.Request(), h.productURL, product.Accessories, "")
|
||||
assignProductCardsImages(requestBaseURL(c.Request()), product.Accessories)
|
||||
|
||||
var profile *pscustomer.Profile
|
||||
if session.CustomerID != nil && h.customers != nil {
|
||||
@@ -145,3 +158,31 @@ func productCategoryURL(req *http.Request, route *psroutes.CategoryRoute, produc
|
||||
LanguagePrefix: requestLanguagePrefix(req),
|
||||
})
|
||||
}
|
||||
|
||||
func assignProductGalleryImages(baseURL string, product *pscatalog.ProductPageData) {
|
||||
if product == nil {
|
||||
return
|
||||
}
|
||||
for i := range product.GalleryImages {
|
||||
id := sqlNullInt64(product.GalleryImages[i].ID)
|
||||
product.GalleryImages[i].URL = prestashopImageURL(baseURL, id, "large_default")
|
||||
product.GalleryImages[i].ThumbURL = prestashopImageURL(baseURL, id, "home_default")
|
||||
}
|
||||
}
|
||||
|
||||
func assignProductCombinationImages(baseURL string, product *pscatalog.ProductPageData) {
|
||||
if product == nil {
|
||||
return
|
||||
}
|
||||
for i := range product.Combinations {
|
||||
product.Combinations[i].ImageURL = prestashopImageURL(baseURL, product.Combinations[i].ImageID, "large_default")
|
||||
product.Combinations[i].ThumbURL = prestashopImageURL(baseURL, product.Combinations[i].ImageID, "home_default")
|
||||
}
|
||||
}
|
||||
|
||||
func sqlNullInt64(value int64) sql.NullInt64 {
|
||||
if value == 0 {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: value, Valid: true}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -12,6 +17,63 @@ type Summary struct {
|
||||
TotalItems int64
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
ID int64
|
||||
Items []Item
|
||||
TotalItems int64
|
||||
Subtotal float64
|
||||
SubtotalTaxIncl float64
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
ProductID int64
|
||||
ProductAttributeID int64
|
||||
CustomizationID int64
|
||||
Name string
|
||||
Slug string
|
||||
CategoryPath string
|
||||
EAN13 string
|
||||
CoverImageID sql.NullInt64 `gorm:"column:cover_image_id"`
|
||||
ImageURL string `gorm:"-"`
|
||||
Quantity int64
|
||||
UnitPrice float64
|
||||
UnitPriceTaxIncl float64 `gorm:"column:unit_price_tax_incl"`
|
||||
LineTotal float64
|
||||
LineTotalTaxIncl float64 `gorm:"column:line_total_tax_incl"`
|
||||
TaxRate float64 `gorm:"column:tax_rate"`
|
||||
CurrencyID int64 `gorm:"column:currency_id"`
|
||||
CurrencyCode string `gorm:"column:currency_code"`
|
||||
CurrencySign string `gorm:"column:currency_sign"`
|
||||
ConversionRate float64 `gorm:"column:conversion_rate"`
|
||||
URL string `gorm:"-"`
|
||||
Attributes []ItemAttribute `gorm:"-"`
|
||||
}
|
||||
|
||||
type ItemAttribute struct {
|
||||
Group string
|
||||
Value string
|
||||
}
|
||||
|
||||
type MutationInput struct {
|
||||
CartID int64
|
||||
ProductID int64
|
||||
ProductAttributeID int64
|
||||
CustomizationID int64
|
||||
Quantity int64
|
||||
CustomerID int64
|
||||
GuestID int64
|
||||
LanguageID int64
|
||||
CurrencyID int64
|
||||
ShopID int64
|
||||
}
|
||||
|
||||
type MutationResult struct {
|
||||
CartID int64
|
||||
LineQuantity int64
|
||||
TotalItems int64
|
||||
CreatedCart bool
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
prefix string
|
||||
@@ -33,3 +95,676 @@ func (s *Service) SummaryByID(ctx context.Context, cartID int64) (*Summary, erro
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *Service) PageByID(ctx context.Context, cartID, languageID, shopID, currencyID int64) (*Page, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("prestashop cart service is not initialized")
|
||||
}
|
||||
if cartID == 0 {
|
||||
return &Page{}, nil
|
||||
}
|
||||
if languageID == 0 {
|
||||
languageID = 1
|
||||
}
|
||||
if shopID == 0 {
|
||||
shopID = 1
|
||||
}
|
||||
if currencyID == 0 {
|
||||
currencyID = 1
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT cp.id_product AS product_id,
|
||||
COALESCE(cp.id_product_attribute, 0) AS product_attribute_id,
|
||||
COALESCE(cp.id_customization, 0) AS customization_id,
|
||||
pl.name AS name,
|
||||
pl.link_rewrite AS slug,
|
||||
cl.link_rewrite AS category_path,
|
||||
p.ean13 AS ean13,
|
||||
COALESCE(combination_image.id_image, i.id_image) AS cover_image_id,
|
||||
cp.quantity AS quantity,
|
||||
((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate) AS unit_price,
|
||||
(((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS unit_price_tax_incl,
|
||||
(cp.quantity * ((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate)) AS line_total,
|
||||
(cp.quantity * (((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100))) AS line_total_tax_incl,
|
||||
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
|
||||
curr.id_currency AS currency_id,
|
||||
curr.iso_code AS currency_code,
|
||||
curr.sign AS currency_sign,
|
||||
curr.conversion_rate AS conversion_rate
|
||||
FROM %scart_product cp
|
||||
JOIN %sproduct p ON p.id_product = cp.id_product
|
||||
JOIN %sproduct_shop product_shop
|
||||
ON product_shop.id_product = cp.id_product
|
||||
AND product_shop.id_shop = cp.id_shop
|
||||
JOIN %sproduct_lang pl
|
||||
ON pl.id_product = cp.id_product
|
||||
AND pl.id_lang = ?
|
||||
AND pl.id_shop = ?
|
||||
LEFT JOIN %simage i ON i.id_product = cp.id_product AND i.cover = 1
|
||||
LEFT JOIN (
|
||||
SELECT pai.id_product_attribute,
|
||||
MIN(pai.id_image) AS id_image
|
||||
FROM %sproduct_attribute_image pai
|
||||
GROUP BY pai.id_product_attribute
|
||||
) combination_image
|
||||
ON combination_image.id_product_attribute = cp.id_product_attribute
|
||||
LEFT JOIN %sproduct_attribute_shop product_attribute_shop
|
||||
ON product_attribute_shop.id_product_attribute = cp.id_product_attribute
|
||||
AND product_attribute_shop.id_shop = cp.id_shop
|
||||
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
|
||||
LEFT JOIN (
|
||||
SELECT tr.id_tax_rules_group,
|
||||
SUM(t.rate) AS tax_rate
|
||||
FROM %stax_rule tr
|
||||
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||
GROUP BY tr.id_tax_rules_group
|
||||
) tax_data ON tax_data.id_tax_rules_group = product_shop.id_tax_rules_group
|
||||
LEFT JOIN %scategory_lang cl
|
||||
ON cl.id_category = product_shop.id_category_default
|
||||
AND cl.id_lang = pl.id_lang
|
||||
AND cl.id_shop = pl.id_shop
|
||||
WHERE cp.id_cart = ?
|
||||
AND product_shop.active = 1
|
||||
ORDER BY cp.date_add ASC, cp.id_product ASC, cp.id_product_attribute ASC
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
|
||||
var items []Item
|
||||
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), languageID, shopID, currencyID, cartID).Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page := &Page{ID: cartID, Items: items}
|
||||
if err := s.loadItemAttributes(ctx, languageID, &page.Items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
page.TotalItems += item.Quantity
|
||||
page.Subtotal += item.LineTotal
|
||||
page.SubtotalTaxIncl += item.LineTotalTaxIncl
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadItemAttributes(ctx context.Context, languageID int64, items *[]Item) error {
|
||||
if items == nil || len(*items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
attributeIDs := make([]int64, 0, len(*items))
|
||||
indexByAttributeID := make(map[int64][]int)
|
||||
for i, item := range *items {
|
||||
if item.ProductAttributeID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := indexByAttributeID[item.ProductAttributeID]; !exists {
|
||||
attributeIDs = append(attributeIDs, item.ProductAttributeID)
|
||||
}
|
||||
indexByAttributeID[item.ProductAttributeID] = append(indexByAttributeID[item.ProductAttributeID], i)
|
||||
}
|
||||
if len(attributeIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type attributeRow struct {
|
||||
ProductAttributeID int64 `gorm:"column:id_product_attribute"`
|
||||
GroupName string `gorm:"column:group_name"`
|
||||
AttributeName string `gorm:"column:attribute_name"`
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT pac.id_product_attribute,
|
||||
agl.public_name AS group_name,
|
||||
al.name AS attribute_name
|
||||
FROM %sproduct_attribute_combination pac
|
||||
JOIN %sattribute a
|
||||
ON a.id_attribute = pac.id_attribute
|
||||
JOIN %sattribute_lang al
|
||||
ON al.id_attribute = a.id_attribute
|
||||
AND al.id_lang = ?
|
||||
JOIN %sattribute_group ag
|
||||
ON ag.id_attribute_group = a.id_attribute_group
|
||||
JOIN %sattribute_group_lang agl
|
||||
ON agl.id_attribute_group = ag.id_attribute_group
|
||||
AND agl.id_lang = ?
|
||||
WHERE pac.id_product_attribute IN ?
|
||||
ORDER BY pac.id_product_attribute ASC, ag.position ASC, a.position ASC, al.name ASC
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
|
||||
rows := make([]attributeRow, 0)
|
||||
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), languageID, languageID, attributeIDs).Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
indices := indexByAttributeID[row.ProductAttributeID]
|
||||
for _, idx := range indices {
|
||||
(*items)[idx].Attributes = append((*items)[idx].Attributes, ItemAttribute{
|
||||
Group: strings.TrimSpace(row.GroupName),
|
||||
Value: strings.TrimSpace(row.AttributeName),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) AddProduct(ctx context.Context, input MutationInput) (*MutationResult, error) {
|
||||
return s.mutateProduct(ctx, mutationAdd, input)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProduct(ctx context.Context, input MutationInput) (*MutationResult, error) {
|
||||
return s.mutateProduct(ctx, mutationSet, input)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteProduct(ctx context.Context, input MutationInput) (*MutationResult, error) {
|
||||
return s.mutateProduct(ctx, mutationDelete, input)
|
||||
}
|
||||
|
||||
type mutationMode string
|
||||
|
||||
const (
|
||||
mutationAdd mutationMode = "add"
|
||||
mutationSet mutationMode = "set"
|
||||
mutationDelete mutationMode = "delete"
|
||||
)
|
||||
|
||||
type productLine struct {
|
||||
CartID int64 `gorm:"column:id_cart"`
|
||||
ProductID int64 `gorm:"column:id_product"`
|
||||
ProductAttributeID int64 `gorm:"column:id_product_attribute"`
|
||||
CustomizationID int64 `gorm:"column:id_customization"`
|
||||
AddressDeliveryID int64 `gorm:"column:id_address_delivery"`
|
||||
Quantity int64 `gorm:"column:quantity"`
|
||||
}
|
||||
|
||||
type cartContext struct {
|
||||
ID int64
|
||||
ShopID int64 `gorm:"column:id_shop"`
|
||||
ShopGroupID int64 `gorm:"column:id_shop_group"`
|
||||
CustomerID int64 `gorm:"column:id_customer"`
|
||||
GuestID int64 `gorm:"column:id_guest"`
|
||||
LanguageID int64 `gorm:"column:id_lang"`
|
||||
CurrencyID int64 `gorm:"column:id_currency"`
|
||||
AddressDeliveryID int64 `gorm:"column:id_address_delivery"`
|
||||
AddressInvoiceID int64 `gorm:"column:id_address_invoice"`
|
||||
SecureKey string
|
||||
}
|
||||
|
||||
func (s *Service) mutateProduct(ctx context.Context, mode mutationMode, input MutationInput) (*MutationResult, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("prestashop cart service is not initialized")
|
||||
}
|
||||
if input.ProductID == 0 {
|
||||
return nil, errors.New("product id is required")
|
||||
}
|
||||
if mode != mutationDelete && input.Quantity <= 0 {
|
||||
return nil, errors.New("quantity must be positive")
|
||||
}
|
||||
if input.ShopID == 0 {
|
||||
input.ShopID = 1
|
||||
}
|
||||
if input.LanguageID == 0 {
|
||||
input.LanguageID = 1
|
||||
}
|
||||
if input.CurrencyID == 0 {
|
||||
input.CurrencyID = 1
|
||||
}
|
||||
|
||||
var result MutationResult
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
cart, created, err := s.ensureCart(tx, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.CartID = cart.ID
|
||||
result.CreatedCart = created
|
||||
|
||||
attributeID, err := s.resolveProductAttributeID(tx, input.ProductID, input.ProductAttributeID, cart.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.ProductAttributeID = attributeID
|
||||
|
||||
if err := s.ensureProductExists(tx, input.ProductID, cart.ShopID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
line, err := s.loadProductLine(tx, cart.ID, input.ProductID, input.ProductAttributeID, input.CustomizationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case mutationDelete:
|
||||
if err := s.deleteProductLine(tx, cart.ID, input.ProductID, input.ProductAttributeID, input.CustomizationID); err != nil {
|
||||
return err
|
||||
}
|
||||
result.LineQuantity = 0
|
||||
case mutationAdd:
|
||||
addressID := cart.AddressDeliveryID
|
||||
if line != nil && line.AddressDeliveryID != 0 {
|
||||
addressID = line.AddressDeliveryID
|
||||
}
|
||||
newQty := input.Quantity
|
||||
if line != nil {
|
||||
newQty += line.Quantity
|
||||
if err := s.updateProductLineQuantity(tx, line, newQty); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.insertProductLine(tx, cart, input.ProductID, input.ProductAttributeID, input.CustomizationID, addressID, newQty); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result.LineQuantity = newQty
|
||||
case mutationSet:
|
||||
if input.Quantity == 0 {
|
||||
if err := s.deleteProductLine(tx, cart.ID, input.ProductID, input.ProductAttributeID, input.CustomizationID); err != nil {
|
||||
return err
|
||||
}
|
||||
result.LineQuantity = 0
|
||||
} else if line != nil {
|
||||
if err := s.updateProductLineQuantity(tx, line, input.Quantity); err != nil {
|
||||
return err
|
||||
}
|
||||
result.LineQuantity = input.Quantity
|
||||
} else {
|
||||
if err := s.insertProductLine(tx, cart, input.ProductID, input.ProductAttributeID, input.CustomizationID, cart.AddressDeliveryID, input.Quantity); err != nil {
|
||||
return err
|
||||
}
|
||||
result.LineQuantity = input.Quantity
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported cart mutation %q", mode)
|
||||
}
|
||||
|
||||
if err := s.touchCart(tx, cart.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary, err := s.summaryByIDWithDB(tx, cart.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.TotalItems = summary.TotalItems
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureCart(tx *gorm.DB, input MutationInput) (*cartContext, bool, error) {
|
||||
if input.CartID != 0 {
|
||||
cart, err := s.loadCart(tx, input.CartID)
|
||||
if err == nil {
|
||||
return cart, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
ctx := cartContext{
|
||||
ShopID: input.ShopID,
|
||||
CustomerID: input.CustomerID,
|
||||
GuestID: input.GuestID,
|
||||
LanguageID: input.LanguageID,
|
||||
CurrencyID: input.CurrencyID,
|
||||
}
|
||||
|
||||
shopGroupID, err := s.loadShopGroupID(tx, ctx.ShopID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
ctx.ShopGroupID = shopGroupID
|
||||
|
||||
if ctx.CustomerID != 0 {
|
||||
ctx.AddressDeliveryID, ctx.AddressInvoiceID, err = s.loadCustomerAddressIDs(tx, ctx.CustomerID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
ctx.SecureKey, err = s.loadCustomerSecureKey(tx, ctx.CustomerID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
cartID, err := s.insertCart(tx, &ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
ctx.ID = cartID
|
||||
return &ctx, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCart(tx *gorm.DB, cartID int64) (*cartContext, error) {
|
||||
var cart cartContext
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id_cart AS id,
|
||||
COALESCE(id_shop, 0) AS id_shop,
|
||||
COALESCE(id_shop_group, 0) AS id_shop_group,
|
||||
COALESCE(id_customer, 0) AS id_customer,
|
||||
COALESCE(id_guest, 0) AS id_guest,
|
||||
COALESCE(id_lang, 0) AS id_lang,
|
||||
COALESCE(id_currency, 0) AS id_currency,
|
||||
COALESCE(id_address_delivery, 0) AS id_address_delivery,
|
||||
COALESCE(id_address_invoice, 0) AS id_address_invoice,
|
||||
COALESCE(secure_key, '') AS secure_key
|
||||
FROM %scart
|
||||
WHERE id_cart = ?
|
||||
LIMIT 1
|
||||
`, s.prefix)
|
||||
err := tx.Raw(strings.TrimSpace(query), cartID).Scan(&cart).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cart.ID == 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return &cart, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadShopGroupID(tx *gorm.DB, shopID int64) (int64, error) {
|
||||
var row struct {
|
||||
ShopGroupID int64 `gorm:"column:id_shop_group"`
|
||||
}
|
||||
query := fmt.Sprintf("SELECT id_shop_group FROM %sshop WHERE id_shop = ? LIMIT 1", s.prefix)
|
||||
if err := tx.Raw(query, shopID).Scan(&row).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if row.ShopGroupID == 0 {
|
||||
return 0, fmt.Errorf("prestashop shop %d not found", shopID)
|
||||
}
|
||||
return row.ShopGroupID, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCustomerAddressIDs(tx *gorm.DB, customerID int64) (int64, int64, error) {
|
||||
var row struct {
|
||||
ID int64 `gorm:"column:id_address"`
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id_address
|
||||
FROM %saddress
|
||||
WHERE id_customer = ?
|
||||
AND deleted = 0
|
||||
ORDER BY id_address ASC
|
||||
LIMIT 1
|
||||
`, s.prefix)
|
||||
if err := tx.Raw(strings.TrimSpace(query), customerID).Scan(&row).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return row.ID, row.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCustomerSecureKey(tx *gorm.DB, customerID int64) (string, error) {
|
||||
var row struct {
|
||||
SecureKey string `gorm:"column:secure_key"`
|
||||
}
|
||||
query := fmt.Sprintf("SELECT COALESCE(secure_key, '') AS secure_key FROM %scustomer WHERE id_customer = ? LIMIT 1", s.prefix)
|
||||
if err := tx.Raw(query, customerID).Scan(&row).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return row.SecureKey, nil
|
||||
}
|
||||
|
||||
func (s *Service) insertCart(tx *gorm.DB, cart *cartContext) (int64, error) {
|
||||
available, err := s.tableColumns(tx, s.prefix+"cart")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
columns := make([]string, 0, 16)
|
||||
values := make([]any, 0, 16)
|
||||
add := func(name string, value any) {
|
||||
if available[name] {
|
||||
columns = append(columns, name)
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
|
||||
add("id_shop_group", cart.ShopGroupID)
|
||||
add("id_shop", cart.ShopID)
|
||||
add("id_address_delivery", cart.AddressDeliveryID)
|
||||
add("id_address_invoice", cart.AddressInvoiceID)
|
||||
add("id_carrier", 0)
|
||||
add("id_currency", cart.CurrencyID)
|
||||
add("id_customer", cart.CustomerID)
|
||||
add("id_guest", cart.GuestID)
|
||||
add("id_lang", cart.LanguageID)
|
||||
add("recyclable", 0)
|
||||
add("gift", 0)
|
||||
add("gift_message", "")
|
||||
add("mobile_theme", 0)
|
||||
add("delivery_option", "")
|
||||
add("secure_key", cart.SecureKey)
|
||||
add("allow_seperated_package", 0)
|
||||
add("date_add", now)
|
||||
add("date_upd", now)
|
||||
|
||||
if err := tx.Exec(insertQuery(s.prefix+"cart", columns), values...).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var row struct {
|
||||
ID int64 `gorm:"column:id"`
|
||||
}
|
||||
if err := tx.Raw("SELECT LAST_INSERT_ID() AS id").Scan(&row).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return 0, errors.New("cart insert did not return an id")
|
||||
}
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveProductAttributeID(tx *gorm.DB, productID, productAttributeID, shopID int64) (int64, error) {
|
||||
if productAttributeID != 0 {
|
||||
var row struct {
|
||||
ID int64 `gorm:"column:id_product_attribute"`
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT pa.id_product_attribute
|
||||
FROM %sproduct_attribute pa
|
||||
WHERE pa.id_product_attribute = ?
|
||||
AND pa.id_product = ?
|
||||
LIMIT 1
|
||||
`, s.prefix)
|
||||
if err := tx.Raw(strings.TrimSpace(query), productAttributeID, productID).Scan(&row).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return 0, fmt.Errorf("product attribute %d does not belong to product %d", productAttributeID, productID)
|
||||
}
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
var row struct {
|
||||
ID int64 `gorm:"column:id_product_attribute"`
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT pa.id_product_attribute
|
||||
FROM %sproduct_attribute pa
|
||||
LEFT JOIN %sproduct_attribute_shop pas
|
||||
ON pas.id_product_attribute = pa.id_product_attribute
|
||||
AND pas.id_shop = ?
|
||||
WHERE pa.id_product = ?
|
||||
ORDER BY CASE WHEN COALESCE(pas.default_on, 0) = 1 THEN 0 ELSE 1 END,
|
||||
pa.id_product_attribute ASC
|
||||
LIMIT 1
|
||||
`, s.prefix, s.prefix)
|
||||
if err := tx.Raw(strings.TrimSpace(query), shopID, productID).Scan(&row).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureProductExists(tx *gorm.DB, productID, shopID int64) error {
|
||||
var row struct {
|
||||
ID int64 `gorm:"column:id_product"`
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT p.id_product
|
||||
FROM %sproduct p
|
||||
JOIN %sproduct_shop ps
|
||||
ON ps.id_product = p.id_product
|
||||
AND ps.id_shop = ?
|
||||
WHERE p.id_product = ?
|
||||
LIMIT 1
|
||||
`, s.prefix, s.prefix)
|
||||
if err := tx.Raw(strings.TrimSpace(query), shopID, productID).Scan(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) loadProductLine(tx *gorm.DB, cartID, productID, productAttributeID, customizationID int64) (*productLine, error) {
|
||||
var line productLine
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id_cart,
|
||||
id_product,
|
||||
COALESCE(id_product_attribute, 0) AS id_product_attribute,
|
||||
COALESCE(id_customization, 0) AS id_customization,
|
||||
COALESCE(id_address_delivery, 0) AS id_address_delivery,
|
||||
quantity
|
||||
FROM %scart_product
|
||||
WHERE id_cart = ?
|
||||
AND id_product = ?
|
||||
AND COALESCE(id_product_attribute, 0) = ?
|
||||
AND COALESCE(id_customization, 0) = ?
|
||||
LIMIT 1
|
||||
`, s.prefix)
|
||||
if err := tx.Raw(strings.TrimSpace(query), cartID, productID, productAttributeID, customizationID).Scan(&line).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if line.CartID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &line, nil
|
||||
}
|
||||
|
||||
func (s *Service) insertProductLine(tx *gorm.DB, cart *cartContext, productID, productAttributeID, customizationID, addressDeliveryID, quantity int64) error {
|
||||
available, err := s.tableColumns(tx, s.prefix+"cart_product")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
columns := make([]string, 0, 8)
|
||||
values := make([]any, 0, 8)
|
||||
add := func(name string, value any) {
|
||||
if available[name] {
|
||||
columns = append(columns, name)
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
|
||||
add("id_product", productID)
|
||||
add("id_product_attribute", productAttributeID)
|
||||
add("id_cart", cart.ID)
|
||||
add("id_address_delivery", addressDeliveryID)
|
||||
add("id_shop", cart.ShopID)
|
||||
add("quantity", quantity)
|
||||
add("date_add", now)
|
||||
add("id_customization", customizationID)
|
||||
|
||||
return tx.Exec(insertQuery(s.prefix+"cart_product", columns), values...).Error
|
||||
}
|
||||
|
||||
func (s *Service) updateProductLineQuantity(tx *gorm.DB, line *productLine, quantity int64) error {
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE %scart_product
|
||||
SET quantity = ?
|
||||
WHERE id_cart = ?
|
||||
AND id_product = ?
|
||||
AND COALESCE(id_product_attribute, 0) = ?
|
||||
AND COALESCE(id_customization, 0) = ?
|
||||
LIMIT 1
|
||||
`, s.prefix)
|
||||
return tx.Exec(strings.TrimSpace(query), quantity, line.CartID, line.ProductID, line.ProductAttributeID, line.CustomizationID).Error
|
||||
}
|
||||
|
||||
func (s *Service) deleteProductLine(tx *gorm.DB, cartID, productID, productAttributeID, customizationID int64) error {
|
||||
query := fmt.Sprintf(`
|
||||
DELETE FROM %scart_product
|
||||
WHERE id_cart = ?
|
||||
AND id_product = ?
|
||||
AND COALESCE(id_product_attribute, 0) = ?
|
||||
AND COALESCE(id_customization, 0) = ?
|
||||
`, s.prefix)
|
||||
return tx.Exec(strings.TrimSpace(query), cartID, productID, productAttributeID, customizationID).Error
|
||||
}
|
||||
|
||||
func (s *Service) touchCart(tx *gorm.DB, cartID int64) error {
|
||||
query := fmt.Sprintf("UPDATE %scart SET date_upd = ? WHERE id_cart = ?", s.prefix)
|
||||
return tx.Exec(query, time.Now().UTC().Format("2006-01-02 15:04:05"), cartID).Error
|
||||
}
|
||||
|
||||
func (s *Service) summaryByIDWithDB(tx *gorm.DB, cartID int64) (*Summary, error) {
|
||||
var summary Summary
|
||||
query := fmt.Sprintf("SELECT id_cart AS id, COALESCE(SUM(quantity), 0) AS total_items FROM %scart_product WHERE id_cart = ? GROUP BY id_cart", s.prefix)
|
||||
result := tx.Raw(query, cartID).Scan(&summary)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return &Summary{ID: cartID}, nil
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *Service) tableColumns(tx *gorm.DB, tableName string) (map[string]bool, error) {
|
||||
type row struct {
|
||||
ColumnName string `gorm:"column:COLUMN_NAME"`
|
||||
}
|
||||
var rows []row
|
||||
query := `
|
||||
SELECT COLUMN_NAME
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
`
|
||||
if err := tx.Raw(query, tableName).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
columns := make(map[string]bool, len(rows))
|
||||
for _, row := range rows {
|
||||
columns[row.ColumnName] = true
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func insertQuery(tableName string, columns []string) string {
|
||||
if len(columns) == 0 {
|
||||
return fmt.Sprintf("INSERT INTO %s () VALUES ()", tableName)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"INSERT INTO %s (%s) VALUES (%s)",
|
||||
tableName,
|
||||
strings.Join(columns, ", "),
|
||||
placeholders(len(columns)),
|
||||
)
|
||||
}
|
||||
|
||||
func placeholders(n int) string {
|
||||
parts := make([]string, n)
|
||||
for i := range parts {
|
||||
parts[i] = "?"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func formatInt64(value int64) string {
|
||||
if value == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type ProductPageRequest struct {
|
||||
Slug string
|
||||
LanguageID int64
|
||||
ShopID int64
|
||||
CurrencyID int64
|
||||
}
|
||||
|
||||
type CategoryPageRequest struct {
|
||||
@@ -22,6 +23,9 @@ type CategoryPageRequest struct {
|
||||
Slug string
|
||||
LanguageID int64
|
||||
ShopID int64
|
||||
CurrencyID int64
|
||||
Page int
|
||||
PerPage int
|
||||
}
|
||||
|
||||
type ProductPageData struct {
|
||||
@@ -31,10 +35,55 @@ type ProductPageData struct {
|
||||
ShortDescription string
|
||||
Description string
|
||||
Price float64
|
||||
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
|
||||
TaxRate float64 `gorm:"column:tax_rate"`
|
||||
CurrencyID int64 `gorm:"column:currency_id"`
|
||||
CurrencyCode string `gorm:"column:currency_code"`
|
||||
CurrencySign string `gorm:"column:currency_sign"`
|
||||
ConversionRate float64 `gorm:"column:conversion_rate"`
|
||||
CoverImageID sql.NullInt64
|
||||
ImageURL string `gorm:"-"`
|
||||
GalleryImages []ProductImage `gorm:"-"`
|
||||
CategoryID int64
|
||||
CategorySlug string
|
||||
CategoryName string
|
||||
Features []ProductFeature `gorm:"-"`
|
||||
Accessories []CategoryProductCard `gorm:"-"`
|
||||
Combinations []ProductCombination `gorm:"-"`
|
||||
DefaultAttribute int64 `gorm:"-"`
|
||||
}
|
||||
|
||||
type ProductImage struct {
|
||||
ID int64 `gorm:"column:id_image"`
|
||||
Cover bool `gorm:"column:cover"`
|
||||
Position int `gorm:"column:position"`
|
||||
URL string `gorm:"-"`
|
||||
ThumbURL string `gorm:"-"`
|
||||
}
|
||||
|
||||
type ProductFeature struct {
|
||||
ID int64 `gorm:"column:id_feature"`
|
||||
Name string `gorm:"column:name"`
|
||||
Value string `gorm:"column:value"`
|
||||
}
|
||||
|
||||
type ProductCombination struct {
|
||||
ID int64 `gorm:"column:id_product_attribute"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
|
||||
DefaultOn bool `gorm:"column:default_on"`
|
||||
ImageID sql.NullInt64 `gorm:"-"`
|
||||
ImageURL string `gorm:"-"`
|
||||
ThumbURL string `gorm:"-"`
|
||||
Attributes []ProductCombinationAttribute `gorm:"-"`
|
||||
}
|
||||
|
||||
type ProductCombinationAttribute struct {
|
||||
Group string
|
||||
PublicName string
|
||||
Value string
|
||||
GroupType string
|
||||
Color string
|
||||
}
|
||||
|
||||
type CategoryPageData struct {
|
||||
@@ -43,16 +92,33 @@ type CategoryPageData struct {
|
||||
Slug string
|
||||
Description string
|
||||
Products []CategoryProductCard `gorm:"-"`
|
||||
Pagination CategoryPagination `gorm:"-"`
|
||||
}
|
||||
|
||||
type CategoryPagination struct {
|
||||
Page int
|
||||
PerPage int
|
||||
TotalItems int64
|
||||
TotalPages int
|
||||
}
|
||||
|
||||
type CategoryProductCard struct {
|
||||
ID int64
|
||||
Name string
|
||||
Slug string
|
||||
URL string `gorm:"-"`
|
||||
Price float64
|
||||
Description string
|
||||
EAN13 string
|
||||
ID int64
|
||||
Name string
|
||||
Slug string
|
||||
CategorySlug string `gorm:"column:category_slug"`
|
||||
URL string `gorm:"-"`
|
||||
ImageURL string `gorm:"-"`
|
||||
Price float64
|
||||
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
|
||||
TaxRate float64 `gorm:"column:tax_rate"`
|
||||
CurrencyID int64 `gorm:"column:currency_id"`
|
||||
CurrencyCode string `gorm:"column:currency_code"`
|
||||
CurrencySign string `gorm:"column:currency_sign"`
|
||||
ConversionRate float64 `gorm:"column:conversion_rate"`
|
||||
CoverImageID sql.NullInt64 `gorm:"column:cover_image_id"`
|
||||
ShortDescription string `gorm:"column:short_description"`
|
||||
EAN13 string
|
||||
}
|
||||
|
||||
type MenuItem struct {
|
||||
@@ -92,54 +158,85 @@ func NewService(db *gorm.DB, prefix string) *Service {
|
||||
|
||||
func (s *Service) GetProductPage(ctx context.Context, req ProductPageRequest) (*ProductPageData, error) {
|
||||
var product ProductPageData
|
||||
if req.CurrencyID == 0 {
|
||||
req.CurrencyID = 1
|
||||
}
|
||||
queryByID := fmt.Sprintf(`
|
||||
SELECT p.id_product AS id,
|
||||
pl.name AS name,
|
||||
pl.link_rewrite AS slug,
|
||||
pl.description_short AS short_description,
|
||||
pl.description AS description,
|
||||
ps.price AS price,
|
||||
(ps.price * curr.conversion_rate) AS price,
|
||||
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
|
||||
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
|
||||
curr.id_currency AS currency_id,
|
||||
curr.iso_code AS currency_code,
|
||||
curr.sign AS currency_sign,
|
||||
curr.conversion_rate AS conversion_rate,
|
||||
i.id_image AS cover_image_id,
|
||||
p.id_category_default AS category_id,
|
||||
cl.link_rewrite AS category_slug,
|
||||
cl.name AS category_name
|
||||
FROM %sproduct p
|
||||
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
|
||||
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
|
||||
LEFT JOIN (
|
||||
SELECT tr.id_tax_rules_group,
|
||||
SUM(t.rate) AS tax_rate
|
||||
FROM %stax_rule tr
|
||||
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||
GROUP BY tr.id_tax_rules_group
|
||||
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
|
||||
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
||||
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||
WHERE p.id_product = ?
|
||||
AND ps.active = 1
|
||||
AND pl.id_lang = ?
|
||||
AND ps.id_shop = ?
|
||||
LIMIT 1
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
queryBySlug := fmt.Sprintf(`
|
||||
SELECT p.id_product AS id,
|
||||
pl.name AS name,
|
||||
pl.link_rewrite AS slug,
|
||||
pl.description_short AS short_description,
|
||||
pl.description AS description,
|
||||
ps.price AS price,
|
||||
(ps.price * curr.conversion_rate) AS price,
|
||||
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
|
||||
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
|
||||
curr.id_currency AS currency_id,
|
||||
curr.iso_code AS currency_code,
|
||||
curr.sign AS currency_sign,
|
||||
curr.conversion_rate AS conversion_rate,
|
||||
i.id_image AS cover_image_id,
|
||||
p.id_category_default AS category_id,
|
||||
cl.link_rewrite AS category_slug,
|
||||
cl.name AS category_name
|
||||
FROM %sproduct p
|
||||
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
|
||||
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
|
||||
LEFT JOIN (
|
||||
SELECT tr.id_tax_rules_group,
|
||||
SUM(t.rate) AS tax_rate
|
||||
FROM %stax_rule tr
|
||||
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||
GROUP BY tr.id_tax_rules_group
|
||||
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
|
||||
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
||||
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||
WHERE pl.link_rewrite = ?
|
||||
AND ps.active = 1
|
||||
AND pl.id_lang = ?
|
||||
AND ps.id_shop = ?
|
||||
LIMIT 1
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
|
||||
var result *gorm.DB
|
||||
if req.ID != 0 {
|
||||
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryByID), req.ID, req.LanguageID, req.ShopID).Scan(&product)
|
||||
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryByID), req.ShopID, req.CurrencyID, req.ID, req.LanguageID).Scan(&product)
|
||||
} else {
|
||||
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryBySlug), req.Slug, req.LanguageID, req.ShopID).Scan(&product)
|
||||
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryBySlug), req.ShopID, req.CurrencyID, req.Slug, req.LanguageID).Scan(&product)
|
||||
}
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
@@ -147,12 +244,316 @@ LIMIT 1
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
features, err := s.loadProductFeatures(ctx, product.ID, req.LanguageID, req.ShopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
product.Features = features
|
||||
images, err := s.loadProductImages(ctx, product.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
product.GalleryImages = images
|
||||
accessories, err := s.loadProductAccessories(ctx, product.ID, req.LanguageID, req.ShopID, req.CurrencyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
product.Accessories = accessories
|
||||
combinations, err := s.loadProductCombinations(ctx, product.ID, req.LanguageID, req.ShopID, req.CurrencyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
product.Combinations = combinations
|
||||
for _, combination := range combinations {
|
||||
if combination.DefaultOn {
|
||||
product.DefaultAttribute = combination.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if product.DefaultAttribute == 0 && len(combinations) > 0 {
|
||||
product.DefaultAttribute = combinations[0].ID
|
||||
}
|
||||
|
||||
return &product, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadProductImages(ctx context.Context, productID int64) ([]ProductImage, error) {
|
||||
if productID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT i.id_image,
|
||||
CASE WHEN COALESCE(i.cover, 0) = 1 THEN 1 ELSE 0 END AS cover,
|
||||
COALESCE(i.position, 0) AS position
|
||||
FROM %simage i
|
||||
WHERE i.id_product = ?
|
||||
ORDER BY CASE WHEN COALESCE(i.cover, 0) = 1 THEN 0 ELSE 1 END,
|
||||
COALESCE(i.position, 0) ASC,
|
||||
i.id_image ASC
|
||||
`, s.prefix)
|
||||
|
||||
images := make([]ProductImage, 0)
|
||||
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), productID).Scan(&images).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadProductAccessories(ctx context.Context, productID, languageID, shopID, currencyID int64) ([]CategoryProductCard, error) {
|
||||
if productID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT p.id_product AS id,
|
||||
pl.name AS name,
|
||||
pl.link_rewrite AS slug,
|
||||
p.ean13 AS ean13,
|
||||
cl.link_rewrite AS category_slug,
|
||||
(ps.price * curr.conversion_rate) AS price,
|
||||
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
|
||||
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
|
||||
curr.id_currency AS currency_id,
|
||||
curr.iso_code AS currency_code,
|
||||
curr.sign AS currency_sign,
|
||||
curr.conversion_rate AS conversion_rate,
|
||||
i.id_image AS cover_image_id,
|
||||
pl.description_short AS short_description
|
||||
FROM %saccessory a
|
||||
JOIN %sproduct p ON p.id_product = a.id_product_2
|
||||
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
|
||||
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
|
||||
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
||||
LEFT JOIN (
|
||||
SELECT tr.id_tax_rules_group,
|
||||
SUM(t.rate) AS tax_rate
|
||||
FROM %stax_rule tr
|
||||
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||
GROUP BY tr.id_tax_rules_group
|
||||
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
|
||||
WHERE a.id_product_1 = ?
|
||||
AND ps.active = 1
|
||||
AND pl.id_lang = ?
|
||||
ORDER BY p.id_product ASC
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
|
||||
products := make([]CategoryProductCard, 0)
|
||||
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), shopID, currencyID, productID, languageID).Scan(&products).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return products, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadProductFeatures(ctx context.Context, productID, languageID, shopID int64) ([]ProductFeature, error) {
|
||||
if productID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT pf.id_feature,
|
||||
fl.name AS name,
|
||||
fvl.value AS value
|
||||
FROM %sfeature_product pf
|
||||
LEFT JOIN %sfeature_lang fl
|
||||
ON fl.id_feature = pf.id_feature
|
||||
AND fl.id_lang = ?
|
||||
LEFT JOIN %sfeature_value_lang fvl
|
||||
ON fvl.id_feature_value = pf.id_feature_value
|
||||
AND fvl.id_lang = ?
|
||||
LEFT JOIN %sfeature f
|
||||
ON f.id_feature = pf.id_feature
|
||||
LEFT JOIN %sfeature_shop fs
|
||||
ON fs.id_feature = f.id_feature
|
||||
AND fs.id_shop = ?
|
||||
WHERE pf.id_product = ?
|
||||
ORDER BY f.position ASC
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
|
||||
features := make([]ProductFeature, 0)
|
||||
if err := s.db.WithContext(ctx).Raw(
|
||||
strings.TrimSpace(query),
|
||||
languageID,
|
||||
languageID,
|
||||
shopID,
|
||||
productID,
|
||||
).Scan(&features).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return features, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadProductCombinations(ctx context.Context, productID, languageID, shopID, currencyID int64) ([]ProductCombination, error) {
|
||||
if productID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type combinationRow struct {
|
||||
ID int64 `gorm:"column:id_product_attribute"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
|
||||
DefaultOn bool `gorm:"column:default_on"`
|
||||
GroupName string `gorm:"column:group_name"`
|
||||
PublicName string `gorm:"column:public_group_name"`
|
||||
Attribute string `gorm:"column:attribute_name"`
|
||||
GroupType string `gorm:"column:group_type"`
|
||||
Color string `gorm:"column:attribute_color"`
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT pa.id_product_attribute,
|
||||
((ps.price + COALESCE(pas.price, 0)) * curr.conversion_rate) AS price,
|
||||
(((ps.price + COALESCE(pas.price, 0)) * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
|
||||
CASE WHEN COALESCE(pas.default_on, pa.default_on, 0) = 1 THEN 1 ELSE 0 END AS default_on,
|
||||
agl.name AS group_name,
|
||||
agl.public_name AS public_group_name,
|
||||
al.name AS attribute_name,
|
||||
ag.group_type AS group_type,
|
||||
a.color AS attribute_color
|
||||
FROM %sproduct_attribute pa
|
||||
JOIN %sproduct_shop ps
|
||||
ON ps.id_product = pa.id_product
|
||||
AND ps.id_shop = ?
|
||||
JOIN %scurrency curr
|
||||
ON curr.id_currency = ?
|
||||
AND curr.deleted = 0
|
||||
LEFT JOIN (
|
||||
SELECT tr.id_tax_rules_group,
|
||||
SUM(t.rate) AS tax_rate
|
||||
FROM %stax_rule tr
|
||||
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||
GROUP BY tr.id_tax_rules_group
|
||||
) tax_data
|
||||
ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
|
||||
LEFT JOIN %sproduct_attribute_shop pas
|
||||
ON pas.id_product_attribute = pa.id_product_attribute
|
||||
AND pas.id_shop = ?
|
||||
JOIN %sproduct_attribute_combination pac
|
||||
ON pac.id_product_attribute = pa.id_product_attribute
|
||||
JOIN %sattribute a
|
||||
ON a.id_attribute = pac.id_attribute
|
||||
JOIN %sattribute_lang al
|
||||
ON al.id_attribute = a.id_attribute
|
||||
AND al.id_lang = ?
|
||||
JOIN %sattribute_group ag
|
||||
ON ag.id_attribute_group = a.id_attribute_group
|
||||
JOIN %sattribute_group_lang agl
|
||||
ON agl.id_attribute_group = ag.id_attribute_group
|
||||
AND agl.id_lang = ?
|
||||
WHERE pa.id_product = ?
|
||||
ORDER BY CASE WHEN COALESCE(pas.default_on, pa.default_on, 0) = 1 THEN 0 ELSE 1 END,
|
||||
pa.id_product_attribute ASC,
|
||||
ag.position ASC,
|
||||
a.position ASC,
|
||||
al.name ASC
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
|
||||
rows := make([]combinationRow, 0)
|
||||
if err := s.db.WithContext(ctx).Raw(
|
||||
strings.TrimSpace(query),
|
||||
shopID,
|
||||
currencyID,
|
||||
shopID,
|
||||
languageID,
|
||||
languageID,
|
||||
productID,
|
||||
).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
combinations := make([]ProductCombination, 0)
|
||||
indexByID := make(map[int64]int)
|
||||
for _, row := range rows {
|
||||
idx, exists := indexByID[row.ID]
|
||||
if !exists {
|
||||
combinations = append(combinations, ProductCombination{
|
||||
ID: row.ID,
|
||||
Price: row.Price,
|
||||
PriceTaxIncl: row.PriceTaxIncl,
|
||||
DefaultOn: row.DefaultOn,
|
||||
})
|
||||
idx = len(combinations) - 1
|
||||
indexByID[row.ID] = idx
|
||||
}
|
||||
if strings.TrimSpace(row.GroupName) != "" || strings.TrimSpace(row.Attribute) != "" {
|
||||
combinations[idx].Attributes = append(combinations[idx].Attributes, ProductCombinationAttribute{
|
||||
Group: row.GroupName,
|
||||
PublicName: row.PublicName,
|
||||
Value: row.Attribute,
|
||||
GroupType: row.GroupType,
|
||||
Color: row.Color,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.loadCombinationImageIDs(ctx, &combinations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return combinations, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCombinationImageIDs(ctx context.Context, combinations *[]ProductCombination) error {
|
||||
if combinations == nil || len(*combinations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
combinationIDs := make([]int64, 0, len(*combinations))
|
||||
indexByID := make(map[int64]int, len(*combinations))
|
||||
for i, combination := range *combinations {
|
||||
if combination.ID == 0 {
|
||||
continue
|
||||
}
|
||||
combinationIDs = append(combinationIDs, combination.ID)
|
||||
indexByID[combination.ID] = i
|
||||
}
|
||||
if len(combinationIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type combinationImageRow struct {
|
||||
ID int64 `gorm:"column:id_product_attribute"`
|
||||
ImageID int64 `gorm:"column:id_image"`
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT pai.id_product_attribute,
|
||||
MIN(pai.id_image) AS id_image
|
||||
FROM %sproduct_attribute_image pai
|
||||
WHERE pai.id_product_attribute IN ?
|
||||
GROUP BY pai.id_product_attribute
|
||||
`, s.prefix)
|
||||
|
||||
rows := make([]combinationImageRow, 0)
|
||||
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), combinationIDs).Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
idx, exists := indexByID[row.ID]
|
||||
if !exists || row.ImageID == 0 {
|
||||
continue
|
||||
}
|
||||
(*combinations)[idx].ImageID = sql.NullInt64{Int64: row.ImageID, Valid: true}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetCategoryPage(ctx context.Context, req CategoryPageRequest) (*CategoryPageData, error) {
|
||||
var category CategoryPageData
|
||||
if req.CurrencyID == 0 {
|
||||
req.CurrencyID = 1
|
||||
}
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PerPage <= 0 {
|
||||
req.PerPage = 20
|
||||
}
|
||||
categoryQuery := fmt.Sprintf(`
|
||||
SELECT c.id_category AS id,
|
||||
cl.name AS name,
|
||||
@@ -213,25 +614,67 @@ LIMIT 1
|
||||
}
|
||||
}
|
||||
|
||||
countQuery := fmt.Sprintf(`
|
||||
SELECT COUNT(*) AS total_items
|
||||
FROM %scategory_product cp
|
||||
JOIN %sproduct p ON p.id_product = cp.id_product
|
||||
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
|
||||
WHERE cp.id_category = ?
|
||||
AND ps.active = 1
|
||||
AND pl.id_lang = ?
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
|
||||
var countRow struct {
|
||||
TotalItems int64 `gorm:"column:total_items"`
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(countQuery), req.ShopID, category.ID, req.LanguageID).Scan(&countRow).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
category.Pagination = CategoryPagination{
|
||||
Page: req.Page,
|
||||
PerPage: req.PerPage,
|
||||
TotalItems: countRow.TotalItems,
|
||||
TotalPages: totalPages(countRow.TotalItems, req.PerPage),
|
||||
}
|
||||
offset := (req.Page - 1) * req.PerPage
|
||||
|
||||
productQuery := fmt.Sprintf(`
|
||||
SELECT p.id_product AS id,
|
||||
pl.name AS name,
|
||||
pl.link_rewrite AS slug,
|
||||
p.ean13 AS ean13,
|
||||
ps.price AS price,
|
||||
pl.description_short AS description
|
||||
(ps.price * curr.conversion_rate) AS price,
|
||||
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
|
||||
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
|
||||
curr.id_currency AS currency_id,
|
||||
curr.iso_code AS currency_code,
|
||||
curr.sign AS currency_sign,
|
||||
curr.conversion_rate AS conversion_rate,
|
||||
i.id_image AS cover_image_id,
|
||||
pl.description_short AS short_description
|
||||
FROM %scategory_product cp
|
||||
JOIN %sproduct p ON p.id_product = cp.id_product
|
||||
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
|
||||
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
|
||||
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||
LEFT JOIN (
|
||||
SELECT tr.id_tax_rules_group,
|
||||
SUM(t.rate) AS tax_rate
|
||||
FROM %stax_rule tr
|
||||
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||
GROUP BY tr.id_tax_rules_group
|
||||
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
|
||||
WHERE cp.id_category = ?
|
||||
AND ps.active = 1
|
||||
AND pl.id_lang = ?
|
||||
AND ps.id_shop = ?
|
||||
ORDER BY cp.position ASC, p.id_product ASC
|
||||
LIMIT 48
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
LIMIT ?
|
||||
OFFSET ?
|
||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||
|
||||
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(productQuery), category.ID, req.LanguageID, req.ShopID).Scan(&category.Products).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(productQuery), req.ShopID, req.CurrencyID, category.ID, req.LanguageID, req.PerPage, offset).Scan(&category.Products).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -266,6 +709,17 @@ func (s *Service) ResolveLanguageID(ctx context.Context, req *http.Request, fall
|
||||
return row.ID
|
||||
}
|
||||
|
||||
func totalPages(totalItems int64, perPage int) int {
|
||||
if totalItems <= 0 || perPage <= 0 {
|
||||
return 0
|
||||
}
|
||||
pages := int(totalItems / int64(perPage))
|
||||
if totalItems%int64(perPage) != 0 {
|
||||
pages++
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
func (s *Service) GetCategoryMenu(ctx context.Context, languageID int64, shopID int64) ([]MenuItem, error) {
|
||||
rootCategoryID, err := s.rootCategoryID(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -129,6 +129,9 @@ func (r *CategoryRoute) Match(path string) (slug string, ok bool) {
|
||||
}
|
||||
|
||||
func (r *CategoryRoute) MatchInfo(path string) (*CategoryMatch, bool) {
|
||||
if hasExcludedStaticSegment(path) {
|
||||
return nil, false
|
||||
}
|
||||
if r == nil || r.regex == nil {
|
||||
return fallbackCategoryMatch(path)
|
||||
}
|
||||
@@ -262,6 +265,9 @@ func (r *ProductRoute) Match(path string) (slug string, ok bool) {
|
||||
}
|
||||
|
||||
func (r *ProductRoute) MatchInfo(path string) (*ProductMatch, bool) {
|
||||
if hasExcludedStaticSegment(path) {
|
||||
return nil, false
|
||||
}
|
||||
if r == nil || r.regex == nil {
|
||||
return fallbackProductMatch(path)
|
||||
}
|
||||
@@ -500,6 +506,9 @@ func fallbackProductMatch(path string) (*ProductMatch, bool) {
|
||||
if path == "" {
|
||||
return nil, false
|
||||
}
|
||||
if hasExcludedStaticSegment(path) {
|
||||
return nil, false
|
||||
}
|
||||
if hasExcludedContentSegment(path) {
|
||||
return nil, false
|
||||
}
|
||||
@@ -543,6 +552,9 @@ func fallbackCategoryMatch(path string) (*CategoryMatch, bool) {
|
||||
if path == "" {
|
||||
return nil, false
|
||||
}
|
||||
if hasExcludedStaticSegment(path) {
|
||||
return nil, false
|
||||
}
|
||||
if hasExcludedContentSegment(path) {
|
||||
return nil, false
|
||||
}
|
||||
@@ -601,3 +613,19 @@ func hasExcludedContentSegment(path string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasExcludedStaticSegment(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
first := path
|
||||
if idx := strings.IndexByte(first, '/'); idx >= 0 {
|
||||
first = first[:idx]
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(first), "img")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package routes
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCategoryRouteDoesNotOwnImagePath(t *testing.T) {
|
||||
route, err := CompileCategoryRoute("/{id}-{rewrite}")
|
||||
if err != nil {
|
||||
t.Fatalf("compile category route: %v", err)
|
||||
}
|
||||
|
||||
if match, ok := route.MatchInfo("/img/p/1/1/9/6/1/2/119612-large_default.webp"); ok || match != nil {
|
||||
t.Fatalf("expected image path to bypass category route, got ok=%v match=%+v", ok, match)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductRouteDoesNotOwnImagePath(t *testing.T) {
|
||||
route, err := CompileProductRoute("/{id}-{rewrite}")
|
||||
if err != nil {
|
||||
t.Fatalf("compile product route: %v", err)
|
||||
}
|
||||
|
||||
if match, ok := route.MatchInfo("/img/p/1/1/9/6/1/2/119612-large_default.webp"); ok || match != nil {
|
||||
t.Fatalf("expected image path to bypass product route, got ok=%v match=%+v", ok, match)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,12 @@ func (e *Engine) Category(w http.ResponseWriter, r *http.Request, data viewmodel
|
||||
return streamComponent(r.Context(), w, component)
|
||||
}
|
||||
|
||||
func (e *Engine) Cart(w http.ResponseWriter, r *http.Request, data viewmodel.CartPageData) error {
|
||||
startHTMLStream(w)
|
||||
component := templates.CartPage(data, e.assets.CSSPath("app.css"), e.assets.JSPath("app.js"))
|
||||
return streamComponent(r.Context(), w, component)
|
||||
}
|
||||
|
||||
func startHTMLStream(w http.ResponseWriter) {
|
||||
if w == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package viewmodel
|
||||
|
||||
import (
|
||||
pscart "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cart"
|
||||
pscatalog "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/catalog"
|
||||
pscookie "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cookie"
|
||||
pscustomer "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/customer"
|
||||
)
|
||||
|
||||
type CartPageData struct {
|
||||
Cart pscart.Page
|
||||
Menu []pscatalog.MenuItem
|
||||
Locale pscatalog.HeaderLocaleData
|
||||
Session *pscookie.SessionContext
|
||||
Customer *pscustomer.Profile
|
||||
CartSummary *pscart.Summary
|
||||
ShopBaseURL string
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
type CategoryPageData struct {
|
||||
Category pscatalog.CategoryPageData
|
||||
Pagination CategoryPagination
|
||||
Menu []pscatalog.MenuItem
|
||||
Locale pscatalog.HeaderLocaleData
|
||||
Session *pscookie.SessionContext
|
||||
@@ -16,3 +17,12 @@ type CategoryPageData struct {
|
||||
CartSummary *pscart.Summary
|
||||
ShopBaseURL string
|
||||
}
|
||||
|
||||
type CategoryPagination struct {
|
||||
Page int
|
||||
PerPage int
|
||||
TotalItems int64
|
||||
TotalPages int
|
||||
PrevURL string
|
||||
NextURL string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user