13 Commits

Author SHA1 Message Date
dd806bbb1e feat: create procedure for retrieving products 2026-03-19 15:44:42 +01:00
Daniel Goc
b6bf6ed5c6 small debug 2026-03-19 10:39:27 +01:00
7a66d6f429 Merge pull request 'test' (#10) from test into main
Reviewed-on: #10
2026-03-19 08:28:37 +00:00
43f856ee8d fix: errors 2026-03-19 09:26:47 +01:00
506c64e240 Merge branch 'main' of ssh://git.ma-al.com:8822/goc_daniel/b2b into test 2026-03-19 08:17:54 +01:00
718b4d23f1 fix: page Cart 2026-03-18 17:06:16 +01:00
22e8556c9d fix: page Addresses 2026-03-18 16:31:29 +01:00
Daniel Goc
52c17d7017 cleanup 2026-03-18 16:09:03 +01:00
Daniel Goc
e094865fc7 endpoint to update JWT cookies 2026-03-18 15:40:54 +01:00
Daniel Goc
01c8f4333f endpoints returning languages, countries and currencies 2026-03-18 14:12:33 +01:00
Daniel Goc
6cebcacb5d new endpoint to return product list 2026-03-18 11:39:18 +01:00
c79e08dbb8 Merge branch 'main' of ssh://git.ma-al.com:8822/goc_daniel/b2b into test 2026-03-18 10:02:44 +01:00
Daniel Goc
a0dcb56fda code refactor 2026-03-17 10:55:17 +01:00
48 changed files with 2499 additions and 421 deletions

View File

@@ -40,6 +40,7 @@ func AuthHandlerRoutes(r fiber.Router) fiber.Router {
r.Post("/reset-password", handler.ResetPassword) r.Post("/reset-password", handler.ResetPassword)
r.Post("/logout", handler.Logout) r.Post("/logout", handler.Logout)
r.Post("/refresh", handler.RefreshToken) r.Post("/refresh", handler.RefreshToken)
r.Post("/update-choice", handler.UpdateJWTToken)
// Google OAuth2 // Google OAuth2
r.Get("/google", handler.GoogleLogin) r.Get("/google", handler.GoogleLogin)
@@ -344,6 +345,11 @@ func (h *AuthHandler) CompleteRegistration(c fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(response) return c.Status(fiber.StatusCreated).JSON(response)
} }
// CompleteRegistration handles completion of registration with password
func (h *AuthHandler) UpdateJWTToken(c fiber.Ctx) error {
return h.UpdateJWTToken(c)
}
// GoogleLogin redirects the user to Google's OAuth2 consent page // GoogleLogin redirects the user to Google's OAuth2 consent page
func (h *AuthHandler) GoogleLogin(c fiber.Ctx) error { func (h *AuthHandler) GoogleLogin(c fiber.Ctx) error {
// Generate a random state token and store it in a short-lived cookie // Generate a random state token and store it in a short-lived cookie
@@ -408,9 +414,12 @@ func (h *AuthHandler) GoogleCallback(c fiber.Ctx) error {
// Redirect to the locale-prefixed charts page after successful Google login. // Redirect to the locale-prefixed charts page after successful Google login.
// The user's preferred language is stored in the auth response; fall back to "en". // The user's preferred language is stored in the auth response; fall back to "en".
lang := response.User.Lang lang, err := h.authService.GetLangISOCode(response.User.LangID)
if lang == "" { if err != nil {
lang = "en" return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadLangID)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadLangID),
})
} }
return c.Redirect().To(h.config.App.BaseURL + "/" + lang) return c.Redirect().To(h.config.App.BaseURL + "/" + lang)
} }

View File

@@ -0,0 +1,52 @@
package restricted
import (
"git.ma-al.com/goc_daniel/b2b/app/service/langsAndCountriesService"
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
"git.ma-al.com/goc_daniel/b2b/app/utils/nullable"
"git.ma-al.com/goc_daniel/b2b/app/utils/response"
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
"github.com/gofiber/fiber/v3"
)
// LangsAndCountriesHandler for getting languages and countries data
type LangsAndCountriesHandler struct {
langsAndCountriesService *langsAndCountriesService.LangsAndCountriesService
}
// NewLangsAndCountriesHandler creates a new LangsAndCountriesHandler instance
func NewLangsAndCountriesHandler() *LangsAndCountriesHandler {
langsAndCountriesService := langsAndCountriesService.New()
return &LangsAndCountriesHandler{
langsAndCountriesService: langsAndCountriesService,
}
}
func LangsAndCountriesHandlerRoutes(r fiber.Router) fiber.Router {
handler := NewLangsAndCountriesHandler()
r.Get("/get-languages", handler.GetLanguages)
r.Get("/get-countries", handler.GetCountries)
return r
}
func (h *LangsAndCountriesHandler) GetLanguages(c fiber.Ctx) error {
languages, err := h.langsAndCountriesService.GetLanguages()
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&languages, 0, i18n.T_(c, response.Message_OK)))
}
func (h *LangsAndCountriesHandler) GetCountries(c fiber.Ctx) error {
countries, err := h.langsAndCountriesService.GetCountriesAndCurrencies()
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&countries, 0, i18n.T_(c, response.Message_OK)))
}

View File

@@ -0,0 +1,120 @@
package restricted
import (
"git.ma-al.com/goc_daniel/b2b/app/config"
"git.ma-al.com/goc_daniel/b2b/app/service/listProductsService"
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
"git.ma-al.com/goc_daniel/b2b/app/utils/nullable"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/filters"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/find"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/query_params"
"git.ma-al.com/goc_daniel/b2b/app/utils/response"
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
"github.com/gofiber/fiber/v3"
"github.com/samber/lo"
"gorm.io/gorm"
)
// ListProductsHandler handles endpoints that receive, save and translate product descriptions.
type ListProductsHandler struct {
listProductsService *listProductsService.ListProductsService
config *config.Config
}
// NewListProductsHandler creates a new ListProductsHandler instance
func NewListProductsHandler() *ListProductsHandler {
listProductsService := listProductsService.New()
return &ListProductsHandler{
listProductsService: listProductsService,
config: config.Get(),
}
}
func ListProductsHandlerRoutes(r fiber.Router) fiber.Router {
handler := NewListProductsHandler()
r.Get("/get-listing", handler.GetListing)
return r
}
func (h *ListProductsHandler) GetListing(c fiber.Ctx) error {
paging, filters, err := ParseProductFilters(c)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
// overrides := map[string]string{
// "override_country": c.Query("override_country", ""),
// "override_currency": c.Query("override_currency", ""),
// }
listing, err := h.listProductsService.GetListing(paging, filters)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&listing.Items, int(listing.Count), i18n.T_(c, response.Message_OK)))
}
var columnMapping map[string]string = map[string]string{}
// var columnMapping map[string]string = map[string]string{
// "product_id": "id",
// "price": "price_taxed",
// "name": "name",
// "category_id": "category_id",
// "feature_id": "feature_id",
// "feature": "feature_name",
// "value_id": "value_id",
// "value": "value_name",
// "status": "active_sale",
// "stock": "in_stock",
// }
func ParseProductFilters(c fiber.Ctx) (find.Paging, *filters.FiltersList, error) {
var p find.Paging
fl := filters.NewFiltersList()
// productFilters := new(model.ProductFilters)
// err := c.Bind().Query(productFilters)
// if err != nil {
// return p, &fl, err
// }
// if productFilters.Name != "" {
// fl.Append(filters.Where("name LIKE ?", fmt.Sprintf("%%%s%%", productFilters.Name)))
// }
// if productFilters.Sort != "" {
// ord, err := query_params.ParseOrdering[model.Product](c, columnMapping)
// if err != nil {
// return p, &fl, err
// }
// for _, o := range ord {
// fl.Append(filters.Order(o.Column, o.IsDesc))
// }
// }
// if len(productFilters.Features) > 0 {
// fl.Append(featureValueFilters(productFilters.Features))
// }
// fl.Append(query_params.ParseWhereScopes[model.Product](c, []string{"name"}, columnMapping)...)
pageNum, pageElems := query_params.ParsePagination(c)
p = find.Paging{Page: pageNum, Elements: pageElems}
return p, &fl, nil
}
type FeatVal = map[uint][]uint
func featureValueFilters(feats FeatVal) filters.Filter {
filt := func(db *gorm.DB) *gorm.DB {
return db.Where("value_id IN ?", lo.Flatten(lo.Values(feats))).Group("id").Having("COUNT(id) = ?", len(lo.Keys(feats)))
}
return filters.NewFilter(filters.FEAT_VAL_PRODUCT_FILTER, filt)
}

View File

@@ -6,6 +6,8 @@ import (
"git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/config"
"git.ma-al.com/goc_daniel/b2b/app/service/productDescriptionService" "git.ma-al.com/goc_daniel/b2b/app/service/productDescriptionService"
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
"git.ma-al.com/goc_daniel/b2b/app/utils/nullable"
"git.ma-al.com/goc_daniel/b2b/app/utils/response"
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
@@ -41,151 +43,131 @@ func ProductDescriptionHandlerRoutes(r fiber.Router) fiber.Router {
func (h *ProductDescriptionHandler) GetProductDescription(c fiber.Ctx) error { func (h *ProductDescriptionHandler) GetProductDescription(c fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint) userID, ok := c.Locals("userID").(uint)
if !ok { if !ok {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), // possibly could return a different error JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
})
} }
productID_attribute := c.Query("productID") productID_attribute := c.Query("productID")
productID, err := strconv.Atoi(productID_attribute) productID, err := strconv.Atoi(productID_attribute)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
})
} }
productShopID_attribute := c.Query("productShopID") productShopID_attribute := c.Query("productShopID")
productShopID, err := strconv.Atoi(productShopID_attribute) productShopID, err := strconv.Atoi(productShopID_attribute)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
})
} }
productLangID_attribute := c.Query("productLangID") productLangID_attribute := c.Query("productLangID")
productLangID, err := strconv.Atoi(productLangID_attribute) productLangID, err := strconv.Atoi(productLangID_attribute)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
})
} }
response, err := h.productDescriptionService.GetProductDescription(userID, uint(productID), uint(productShopID), uint(productLangID)) description, err := h.productDescriptionService.GetProductDescription(userID, uint(productID), uint(productShopID), uint(productLangID))
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(err)).
"error": responseErrors.GetErrorCode(c, err), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
})
} }
return c.JSON(response) return c.JSON(response.Make(description, 1, i18n.T_(c, response.Message_OK)))
} }
// SaveProductDescription saves the description for a given product ID, in given shop and language // SaveProductDescription saves the description for a given product ID, in given shop and language
func (h *ProductDescriptionHandler) SaveProductDescription(c fiber.Ctx) error { func (h *ProductDescriptionHandler) SaveProductDescription(c fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint) userID, ok := c.Locals("userID").(uint)
if !ok { if !ok {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), // possibly could return a different error JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
})
} }
productID_attribute := c.Query("productID") productID_attribute := c.Query("productID")
productID, err := strconv.Atoi(productID_attribute) productID, err := strconv.Atoi(productID_attribute)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
})
} }
productShopID_attribute := c.Query("productShopID") productShopID_attribute := c.Query("productShopID")
productShopID, err := strconv.Atoi(productShopID_attribute) productShopID, err := strconv.Atoi(productShopID_attribute)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
})
} }
productLangID_attribute := c.Query("productLangID") productLangID_attribute := c.Query("productLangID")
productLangID, err := strconv.Atoi(productLangID_attribute) productLangID, err := strconv.Atoi(productLangID_attribute)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
})
} }
updates := make(map[string]string) updates := make(map[string]string)
if err := c.Bind().Body(&updates); err != nil { if err := c.Bind().Body(&updates); err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
})
} }
err = h.productDescriptionService.SaveProductDescription(userID, uint(productID), uint(productShopID), uint(productLangID), updates) err = h.productDescriptionService.SaveProductDescription(userID, uint(productID), uint(productShopID), uint(productLangID), updates)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(err)).
"error": responseErrors.GetErrorCode(c, err), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
})
} }
return c.JSON(fiber.Map{ return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK)))
"message": i18n.T_(c, "product_description.successfully_updated_fields"),
})
} }
// GetProductDescription returns the product description for a given product ID // TranslateProductDescription returns translated product description
func (h *ProductDescriptionHandler) TranslateProductDescription(c fiber.Ctx) error { func (h *ProductDescriptionHandler) TranslateProductDescription(c fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint) userID, ok := c.Locals("userID").(uint)
if !ok { if !ok {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), // possibly could return a different error JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
})
} }
productID_attribute := c.Query("productID") productID_attribute := c.Query("productID")
productID, err := strconv.Atoi(productID_attribute) productID, err := strconv.Atoi(productID_attribute)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
})
} }
productShopID_attribute := c.Query("productShopID") productShopID_attribute := c.Query("productShopID")
productShopID, err := strconv.Atoi(productShopID_attribute) productShopID, err := strconv.Atoi(productShopID_attribute)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
})
} }
productFromLangID_attribute := c.Query("productFromLangID") productFromLangID_attribute := c.Query("productFromLangID")
productFromLangID, err := strconv.Atoi(productFromLangID_attribute) productFromLangID, err := strconv.Atoi(productFromLangID_attribute)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
})
} }
productToLangID_attribute := c.Query("productToLangID") productToLangID_attribute := c.Query("productToLangID")
productToLangID, err := strconv.Atoi(productToLangID_attribute) productToLangID, err := strconv.Atoi(productToLangID_attribute)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
})
} }
model := c.Query("model") aiModel := c.Query("model")
if model != "OpenAI" && model != "Google" { if aiModel != "OpenAI" && aiModel != "Google" {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
})
} }
response, err := h.productDescriptionService.TranslateProductDescription(userID, uint(productID), uint(productShopID), uint(productFromLangID), uint(productToLangID), model) description, err := h.productDescriptionService.TranslateProductDescription(userID, uint(productID), uint(productShopID), uint(productFromLangID), uint(productToLangID), aiModel)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ return c.Status(responseErrors.GetErrorStatus(err)).
"error": responseErrors.GetErrorCode(c, err), JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
})
} }
return c.JSON(response) return c.JSON(response.Make(description, 1, i18n.T_(c, response.Message_OK)))
} }

View File

@@ -86,6 +86,6 @@ func (h *SettingsHandler) GetSettings(cfg *config.Config) fiber.Handler {
Version: version.GetInfo(), Version: version.GetInfo(),
} }
return c.JSON(response.Make(c, fiber.StatusOK, nullable.GetNil(settings), nullable.GetNil(0), i18n.T_(c, response.Message_OK))) return c.JSON(response.Make(nullable.GetNil(settings), 0, i18n.T_(c, response.Message_OK)))
} }
} }

View File

@@ -89,10 +89,19 @@ func (s *Server) Setup() error {
auth := s.public.Group("/auth") auth := s.public.Group("/auth")
public.AuthHandlerRoutes(auth) public.AuthHandlerRoutes(auth)
// Repo routes (restricted) // product description routes (restricted)
productDescription := s.restricted.Group("/product-description") productDescription := s.restricted.Group("/product-description")
restricted.ProductDescriptionHandlerRoutes(productDescription) restricted.ProductDescriptionHandlerRoutes(productDescription)
// listing products routes (restricted)
listProducts := s.restricted.Group("/list-products")
restricted.ListProductsHandlerRoutes(listProducts)
// changing the JWT cookies routes (restricted)
// in reality it just handles changing user's country and language
langsAndCountries := s.restricted.Group("/langs-and-countries")
restricted.LangsAndCountriesHandlerRoutes(langsAndCountries)
// // Restricted routes example // // Restricted routes example
// restricted := s.api.Group("/restricted") // restricted := s.api.Group("/restricted")
// restricted.Use(middleware.AuthMiddleware()) // restricted.Use(middleware.AuthMiddleware())

11
app/model/countries.go Normal file
View File

@@ -0,0 +1,11 @@
package model
// Represents a country together with its associated currency
type Country struct {
ID uint `gorm:"primaryKey;column:id" json:"id"`
Name string `gorm:"column:name" json:"name"`
Flag string `gorm:"size:16;not null;column:flag" json:"flag"`
CurrencyID uint `gorm:"column:id_currency" json:"currency_id"`
CurrencyISOCode string `gorm:"column:iso_code" json:"currency_iso_code"`
CurrencyName string `gorm:"column:name" json:"currency_name"`
}

View File

@@ -25,7 +25,8 @@ type Customer struct {
PasswordResetExpires *time.Time `json:"-"` PasswordResetExpires *time.Time `json:"-"`
LastPasswordResetRequest *time.Time `json:"-"` LastPasswordResetRequest *time.Time `json:"-"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"` LastLoginAt *time.Time `json:"last_login_at,omitempty"`
Lang string `gorm:"size:10;default:'en'" json:"lang"` // User's preferred language LangID uint `gorm:"default:2" json:"lang_id"` // User's preferred language
CountryID uint `gorm:"default:2" json:"country_id"` // User's selected country
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
@@ -76,9 +77,8 @@ type UserSession struct {
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username"` Username string `json:"username"`
Role CustomerRole `json:"role"` Role CustomerRole `json:"role"`
FirstName string `json:"first_name"` LangID uint `json:"lang_id"`
LastName string `json:"last_name"` CountryID uint `json:"country_id"`
Lang string `json:"lang"`
} }
// ToSession converts User to UserSession // ToSession converts User to UserSession
@@ -87,9 +87,8 @@ func (u *Customer) ToSession() *UserSession {
UserID: u.ID, UserID: u.ID,
Email: u.Email, Email: u.Email,
Role: u.Role, Role: u.Role,
FirstName: u.FirstName, LangID: u.LangID,
LastName: u.LastName, CountryID: u.CountryID,
Lang: u.Lang,
} }
} }
@@ -107,7 +106,8 @@ type RegisterRequest struct {
ConfirmPassword string `json:"confirm_password" form:"confirm_password"` ConfirmPassword string `json:"confirm_password" form:"confirm_password"`
FirstName string `json:"first_name" form:"first_name"` FirstName string `json:"first_name" form:"first_name"`
LastName string `json:"last_name" form:"last_name"` LastName string `json:"last_name" form:"last_name"`
Lang string `form:"lang" json:"lang"` LangID uint `form:"lang_id" json:"lang_id"`
CountryID uint `form:"country_id" json:"country_id"`
} }
// CompleteRegistrationRequest represents the completion of registration with email verification // CompleteRegistrationRequest represents the completion of registration with email verification

77
app/model/product.go Normal file
View File

@@ -0,0 +1,77 @@
package model
// Product contains each and every column from the table ps_product.
type Product struct {
ProductID uint `gorm:"column:id_product;primaryKey" json:"product_id" form:"product_id"`
SupplierID uint `gorm:"column:id_supplier" json:"supplier_id" form:"supplier_id"`
ManufacturerID uint `gorm:"column:id_manufacturer" json:"manufacturer_id" form:"manufacturer_id"`
CategoryDefaultID uint `gorm:"column:id_category_default" json:"category_default_id" form:"category_default_id"`
ShopDefaultID uint `gorm:"column:id_shop_default" json:"shop_default_id" form:"shop_default_id"`
TaxRulesGroupID uint `gorm:"column:id_tax_rules_group" json:"tax_rules_group_id" form:"tax_rules_group_id"`
OnSale uint `gorm:"column:on_sale" json:"on_sale" form:"on_sale"`
OnlineOnly uint `gorm:"column:online_only" json:"online_only" form:"online_only"`
EAN13 string `gorm:"column:ean13;type:varchar(13)" json:"ean13" form:"ean13"`
ISBN string `gorm:"column:isbn;type:varchar(32)" json:"isbn" form:"isbn"`
UPC string `gorm:"column:upc;type:varchar(12)" json:"upc" form:"upc"`
EkoTax float32 `gorm:"column:eko_tax;type:decimal(20,6)" json:"eko_tax" form:"eko_tax"`
Quantity uint `gorm:"column:quantity" json:"quantity" form:"quantity"`
MinimalQuantity uint `gorm:"column:minimal_quantity" json:"minimal_quantity" form:"minimal_quantity"`
LowStockThreshold uint `gorm:"column:low_stock_threshold" json:"low_stock_threshold" form:"low_stock_threshold"`
LowStockAlert uint `gorm:"column:low_stock_alert" json:"low_stock_alert" form:"low_stock_alert"`
Price float32 `gorm:"column:price;type:decimal(20,6)" json:"price" form:"price"`
WholesalePrice float32 `gorm:"column:wholesale_price;type:decimal(20,6)" json:"wholesale_price" form:"wholesale_price"`
Unity string `gorm:"column:unity;type:varchar(255)" json:"unity" form:"unity"`
UnitPriceRatio float32 `gorm:"column:unit_price_ratio;type:decimal(20,6)" json:"unit_price_ratio" form:"unit_price_ratio"`
UnitID uint `gorm:"column:id_unit;primaryKey" json:"unit_id" form:"unit_id"`
AdditionalShippingCost float32 `gorm:"column:additional_shipping_cost;type:decimal(20,2)" json:"additional_shipping_cost" form:"additional_shipping_cost"`
Reference string `gorm:"column:reference;type:varchar(64)" json:"reference" form:"reference"`
SupplierReference string `gorm:"column:supplier_reference;type:varchar(64)" json:"supplier_reference" form:"supplier_reference"`
Location string `gorm:"column:location;type:varchar(64)" json:"location" form:"location"`
Width float32 `gorm:"column:width;type:decimal(20,6)" json:"width" form:"width"`
Height float32 `gorm:"column:height;type:decimal(20,6)" json:"height" form:"height"`
Depth float32 `gorm:"column:depth;type:decimal(20,6)" json:"depth" form:"depth"`
Weight float32 `gorm:"column:weight;type:decimal(20,6)" json:"weight" form:"weight"`
OutOfStock uint `gorm:"column:out_of_stock" json:"out_of_stock" form:"out_of_stock"`
AdditionalDeliveryTimes uint `gorm:"column:additional_delivery_times" json:"additional_delivery_times" form:"additional_delivery_times"`
QuantityDiscount uint `gorm:"column:quantity_discount" json:"quantity_discount" form:"quantity_discount"`
Customizable uint `gorm:"column:customizable" json:"customizable" form:"customizable"`
UploadableFiles uint `gorm:"column:uploadable_files" json:"uploadable_files" form:"uploadable_files"`
TextFields uint `gorm:"column:text_fields" json:"text_fields" form:"text_fields"`
Active uint `gorm:"column:active" json:"active" form:"active"`
RedirectType string `gorm:"column:redirect_type;type:enum('','404','301-product','302-product','301-category','302-category')" json:"redirect_type" form:"redirect_type"`
TypeRedirectedID int `gorm:"column:id_type_redirected" json:"type_redirected_id" form:"type_redirected_id"`
AvailableForOrder uint `gorm:"column:available_for_order" json:"available_for_order" form:"available_for_order"`
AvailableDate string `gorm:"column:available_date;type:date" json:"available_date" form:"available_date"`
ShowCondition uint `gorm:"column:show_condition" json:"show_condition" form:"show_condition"`
Condition string `gorm:"column:condition;type:enum('new','used','refurbished')" json:"condition" form:"condition"`
ShowPrice uint `gorm:"column:show_price" json:"show_price" form:"show_price"`
Indexed uint `gorm:"column:indexed" json:"indexed" form:"indexed"`
Visibility string `gorm:"column:visibility;type:enum('both','catalog','search','none')" json:"visibility" form:"visibility"`
CacheIsPack uint `gorm:"column:cache_is_pack" json:"cache_is_pack" form:"cache_is_pack"`
CacheHasAttachments uint `gorm:"column:cache_has_attachments" json:"cache_has_attachments" form:"cache_has_attachments"`
IsVirtual uint `gorm:"column:is_virtual" json:"is_virtual" form:"is_virtual"`
CacheDefaultAttribute uint `gorm:"column:cache_default_attribute" json:"cache_default_attribute" form:"cache_default_attribute"`
DateAdd string `gorm:"column:date_add;type:datetime" json:"date_add" form:"date_add"`
DateUpd string `gorm:"column:date_upd;type:datetime" json:"date_upd" form:"date_upd"`
AdvancedStockManagement uint `gorm:"column:advanced_stock_management" json:"advanced_stock_management" form:"advanced_stock_management"`
PackStockType uint `gorm:"column:pack_stock_type" json:"pack_stock_type" form:"pack_stock_type"`
State uint `gorm:"column:state" json:"state" form:"state"`
DeliveryDays uint `gorm:"column:delivery_days" json:"delivery_days" form:"delivery_days"`
}
type ProductFilters struct {
Sort string `json:"sort,omitempty" query:"sort,omitempty" example:"price,asc;name,desc"` // sort rule
ProductID uint `json:"product_id,omitempty" query:"product_id,omitempty" example:"1"`
Price float64 `json:"price,omitempty" query:"price,omitempty" example:"123.45"`
Name string `json:"name,omitempty" query:"name,omitempty" example:"Sztabka Złota Britannia"`
CategoryID uint `json:"category_id,omitempty" query:"category_id,omitempty" example:"2"`
CategoryName string `json:"category_name,omitempty" query:"category_name,omitempty" example:"Złote Monety"`
Features FeatVal `query:"features,omitempty"`
ActiveSale bool `query:"sale_active,omitempty"`
InStock uint `query:"stock,omitempty"`
}
type FeatVal = map[uint][]uint

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"strconv"
"time" "time"
"git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/config"
@@ -13,9 +14,13 @@ import (
"git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/service/emailService" "git.ma-al.com/goc_daniel/b2b/app/service/emailService"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
"git.ma-al.com/goc_daniel/b2b/app/utils/nullable"
"git.ma-al.com/goc_daniel/b2b/app/utils/response"
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -27,8 +32,9 @@ type JWTClaims struct {
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username"` Username string `json:"username"`
Role model.CustomerRole `json:"customer_role"` Role model.CustomerRole `json:"customer_role"`
FirstName string `json:"first_name"` CartsIDs []uint `json:"carts_ids"`
LastName string `json:"last_name"` LangID uint `json:"lang_id"`
CountryID uint `json:"country_id"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -149,7 +155,8 @@ func (s *AuthService) Register(req *model.RegisterRequest) error {
EmailVerified: false, EmailVerified: false,
EmailVerificationToken: token, EmailVerificationToken: token,
EmailVerificationExpires: &expiresAt, EmailVerificationExpires: &expiresAt,
Lang: req.Lang, LangID: req.LangID,
CountryID: req.CountryID,
} }
if err := s.db.Create(&user).Error; err != nil { if err := s.db.Create(&user).Error; err != nil {
@@ -158,10 +165,11 @@ func (s *AuthService) Register(req *model.RegisterRequest) error {
// Send verification email // Send verification email
baseURL := config.Get().App.BaseURL baseURL := config.Get().App.BaseURL
lang := req.Lang lang, err := s.GetLangISOCode(req.LangID)
if lang == "" { if err != nil {
lang = "en" // Default to English return responseErrors.ErrBadLangID
} }
if err := s.email.SendVerificationEmail(user.Email, user.EmailVerificationToken, baseURL, lang); err != nil { if err := s.email.SendVerificationEmail(user.Email, user.EmailVerificationToken, baseURL, lang); err != nil {
// Log error but don't fail registration - user can request resend // Log error but don't fail registration - user can request resend
_ = err _ = err
@@ -266,10 +274,11 @@ func (s *AuthService) RequestPasswordReset(emailAddr string) error {
// Send password reset email // Send password reset email
baseURL := config.Get().App.BaseURL baseURL := config.Get().App.BaseURL
lang := "en" lang, err := s.GetLangISOCode(user.LangID)
if user.Lang != "" { if err != nil {
lang = user.Lang return responseErrors.ErrBadLangID
} }
if err := s.email.SendPasswordResetEmail(user.Email, user.PasswordResetToken, baseURL, lang); err != nil { if err := s.email.SendPasswordResetEmail(user.Email, user.PasswordResetToken, baseURL, lang); err != nil {
_ = err _ = err
} }
@@ -471,13 +480,24 @@ func hashToken(raw string) string {
// generateAccessToken generates a short-lived JWT access token // generateAccessToken generates a short-lived JWT access token
func (s *AuthService) generateAccessToken(user *model.Customer) (string, error) { func (s *AuthService) generateAccessToken(user *model.Customer) (string, error) {
_, err := s.GetLangISOCode(user.LangID)
if err != nil {
return "", responseErrors.ErrBadLangID
}
err = s.CheckIfCountryExists(user.CountryID)
if err != nil {
return "", responseErrors.ErrBadCountryID
}
claims := JWTClaims{ claims := JWTClaims{
UserID: user.ID, UserID: user.ID,
Email: user.Email, Email: user.Email,
Username: user.Email, Username: user.Email,
Role: user.Role, Role: user.Role,
FirstName: user.FirstName, CartsIDs: []uint{},
LastName: user.LastName, LangID: user.LangID,
CountryID: user.CountryID,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.JWTExpiration) * time.Second)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.JWTExpiration) * time.Second)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
@@ -488,6 +508,84 @@ func (s *AuthService) generateAccessToken(user *model.Customer) (string, error)
return token.SignedString([]byte(s.config.JWTSecret)) return token.SignedString([]byte(s.config.JWTSecret))
} }
func (s *AuthService) UpdateJWTToken(c fiber.Ctx) error {
// Get user ID from JWT claims in context (set by auth middleware)
claims, ok := c.Locals("jwt_claims").(*JWTClaims)
if !ok || claims == nil {
return c.Status(fiber.StatusUnauthorized).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrNotAuthenticated)))
}
var user model.Customer
// Find user by ID
if err := s.db.First(&user, claims.UserID).Error; err != nil {
return err
}
// Parse language and country_id from query params
langIDStr := c.Query("lang_id")
var langID uint
if langIDStr != "" {
parsedID, err := strconv.ParseUint(langIDStr, 10, 32)
if err != nil {
return c.Status(fiber.StatusBadRequest).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadLangID)))
}
langID = uint(parsedID)
_, err = s.GetLangISOCode(langID)
if err != nil {
return responseErrors.ErrBadLangID
} else {
user.LangID = langID
}
}
countryIDStr := c.Query("country_id")
var countryID uint
if countryIDStr != "" {
parsedID, err := strconv.ParseUint(countryIDStr, 10, 32)
if err != nil {
return c.Status(fiber.StatusBadRequest).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadCountryID)))
}
countryID = uint(parsedID)
err = s.CheckIfCountryExists(countryID)
if err != nil {
return responseErrors.ErrBadCountryID
} else {
user.CountryID = countryID
}
}
// Update choice and get new token using AuthService
newToken, err := s.generateAccessToken(&user)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
// Save the updated user
if err := s.db.Save(&user).Error; err != nil {
return fmt.Errorf("database error: %w", err)
}
// Set the new JWT cookie
cookie := new(fiber.Cookie)
cookie.Name = "jwt_token"
cookie.Value = newToken
cookie.HTTPOnly = true
cookie.Secure = true
cookie.SameSite = fiber.CookieSameSiteLaxMode
c.Cookie(cookie)
return c.JSON(response.Make(&fiber.Map{"token": newToken}, 0, i18n.T_(c, response.Message_OK)))
}
// generateVerificationToken generates a random verification token // generateVerificationToken generates a random verification token
func (s *AuthService) generateVerificationToken() (string, error) { func (s *AuthService) generateVerificationToken() (string, error) {
bytes := make([]byte, 32) bytes := make([]byte, 32)
@@ -507,3 +605,29 @@ func validatePassword(password string) error {
return nil return nil
} }
func (s *AuthService) GetLangISOCode(langID uint) (string, error) {
var lang string
if langID == 0 { // retrieve the default lang
err := db.DB.Table("b2b_language").Where("is_default = ?", 1).Select("iso_code").Scan(&lang).Error
return lang, err
} else {
err := db.DB.Table("b2b_language").Where("id = ?", langID).Where("active = ?", 1).Select("iso_code").Scan(&lang).Error
return lang, err
}
}
func (s *AuthService) CheckIfCountryExists(countryID uint) error {
var count int64
err := db.DB.Table("b2b_countries").Where("id = ?", countryID).Count(&count).Error
if err != nil {
return err
}
if count == 0 {
return responseErrors.ErrBadCountryID
}
return nil
}

View File

@@ -153,7 +153,7 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model.
Role: model.RoleUser, Role: model.RoleUser,
IsActive: true, IsActive: true,
EmailVerified: true, EmailVerified: true,
Lang: "en", LangID: 2,
} }
if err := s.db.Create(&newUser).Error; err != nil { if err := s.db.Create(&newUser).Error; err != nil {

View File

@@ -0,0 +1,26 @@
package langsAndCountriesService
import (
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/repository/langsAndCountriesRepo"
)
// LangsAndCountriesService literally sends back language and countries information.
type LangsAndCountriesService struct {
repo langsAndCountriesRepo.UILangsAndCountriesRepo
}
// NewLangsAndCountriesService creates a new LangsAndCountries service
func New() *LangsAndCountriesService {
return &LangsAndCountriesService{
repo: langsAndCountriesRepo.New(),
}
}
func (s *LangsAndCountriesService) GetLanguages() ([]model.Language, error) {
return s.repo.GetLanguages()
}
func (s *LangsAndCountriesService) GetCountriesAndCurrencies() ([]model.Country, error) {
return s.repo.GetCountriesAndCurrencies()
}

View File

@@ -27,9 +27,10 @@ var LangSrv *LangService
func (s *LangService) GetActive(c fiber.Ctx) response.Response[[]view.Language] { func (s *LangService) GetActive(c fiber.Ctx) response.Response[[]view.Language] {
res, err := s.repo.GetActive() res, err := s.repo.GetActive()
if err != nil { if err != nil {
return response.Make[[]view.Language](c, fiber.StatusBadRequest, nil, nil, i18n.T_(c, response.Message_NOK)) c.Status(fiber.StatusBadRequest)
return response.Make[[]view.Language](nil, 0, i18n.T_(c, response.Message_NOK))
} }
return response.Make(c, fiber.StatusOK, nullable.GetNil(res), nullable.GetNil(len(res)), i18n.T_(c, response.Message_OK)) return response.Make(nullable.GetNil(res), 0, i18n.T_(c, response.Message_OK))
} }
// LoadTranslations loads all translations from the database into the cache // LoadTranslations loads all translations from the database into the cache
@@ -54,25 +55,27 @@ func (s *LangService) ReloadTranslations() error {
func (s *LangService) GetTranslations(c fiber.Ctx, langID uint, scope string, components []string) response.Response[*i18n.TranslationResponse] { func (s *LangService) GetTranslations(c fiber.Ctx, langID uint, scope string, components []string) response.Response[*i18n.TranslationResponse] {
translations, err := i18n.TransStore.GetTranslations(langID, scope, components) translations, err := i18n.TransStore.GetTranslations(langID, scope, components)
if err != nil { if err != nil {
return response.Make[*i18n.TranslationResponse](c, fiber.StatusBadRequest, nil, nil, i18n.T_(c, Message_TranslationsNOK)) c.Status(fiber.StatusBadRequest)
return response.Make[*i18n.TranslationResponse](nil, 0, i18n.T_(c, Message_TranslationsNOK))
} }
return response.Make(c, fiber.StatusOK, nullable.GetNil(translations), nil, i18n.T_(c, Message_TranslationsOK)) return response.Make(nullable.GetNil(translations), 0, i18n.T_(c, Message_TranslationsOK))
} }
// GetAllTranslations returns all translations from the cache // GetAllTranslations returns all translations from the cache
func (s *LangService) GetAllTranslationsResponse(c fiber.Ctx) response.Response[*i18n.TranslationResponse] { func (s *LangService) GetAllTranslationsResponse(c fiber.Ctx) response.Response[*i18n.TranslationResponse] {
translations := i18n.TransStore.GetAllTranslations() translations := i18n.TransStore.GetAllTranslations()
return response.Make(c, fiber.StatusOK, nullable.GetNil(translations), nil, i18n.T_(c, Message_TranslationsOK)) return response.Make(nullable.GetNil(translations), 0, i18n.T_(c, Message_TranslationsOK))
} }
// ReloadTranslationsResponse returns response after reloading translations // ReloadTranslationsResponse returns response after reloading translations
func (s *LangService) ReloadTranslationsResponse(c fiber.Ctx) response.Response[map[string]string] { func (s *LangService) ReloadTranslationsResponse(c fiber.Ctx) response.Response[map[string]string] {
err := s.ReloadTranslations() err := s.ReloadTranslations()
if err != nil { if err != nil {
return response.Make[map[string]string](c, fiber.StatusInternalServerError, nil, nil, i18n.T_(c, Message_LangsNotLoaded)) c.Status(fiber.StatusInternalServerError)
return response.Make[map[string]string](nil, 0, i18n.T_(c, Message_LangsNotLoaded))
} }
result := map[string]string{"status": "success"} result := map[string]string{"status": "success"}
return response.Make(c, fiber.StatusOK, nullable.GetNil(result), nil, i18n.T_(c, Message_LangsLoaded)) return response.Make(nullable.GetNil(result), 0, i18n.T_(c, Message_LangsLoaded))
} }
// GetDefaultLanguage returns the default language // GetDefaultLanguage returns the default language

View File

@@ -0,0 +1,59 @@
package listProductsService
import (
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/filters"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/find"
"git.ma-al.com/goc_daniel/b2b/repository/listProductsRepo"
)
type ListProductsService struct {
listProductsRepo listProductsRepo.UIListProductsRepo
}
func New() *ListProductsService {
return &ListProductsService{
listProductsRepo: listProductsRepo.New(),
}
}
func (s *ListProductsService) GetListing(p find.Paging, filters *filters.FiltersList) (find.Found[model.Product], error) {
var products find.Found[model.Product]
// currencyIso := c.Cookies("currency_iso", "")
// countryIso := c.Cookies("country_iso", "")
// if overrides["override_currency"] != "" {
// currencyIso = overrides["override_currency"]
// }
// if overrides["override_country"] != "" {
// countryIso = overrides["override_country"]
// }
products, err := s.listProductsRepo.GetListing(p, filters)
if err != nil {
return products, err
}
// var loopErr error
// parallel.ForEach(products.Items, func(t model.Product, i int) {
// // products.Items[i].PriceTaxed *= currRate.Rate.InexactFloat64()
// // products.Items[i].PriceTaxed = tiny_util.RoundUpMonetary(products.Items[i].PriceTaxed)
// if products.Items[i].Name.IsNull() {
// translation, err := s.listProductsRepo.GetTranslation(ctx, products.Items[i].ID, defaults.DefaultLanguageID)
// if err != nil {
// loopErr = err
// return
// }
// products.Items[i].Name = nullable.FromPrimitiveString(translation.Name)
// products.Items[i].DescriptionShort = nullable.FromPrimitiveString(translation.DescriptionShort)
// products.Items[i].LinkRewrite = nullable.FromPrimitiveString(translation.LinkRewrite)
// }
// })
// if loopErr != nil {
// return products, errs.Handled(span, loopErr, errs.InternalError, errs.ERR_TODO)
// }
return products, nil
}

View File

@@ -12,32 +12,26 @@ import (
"strings" "strings"
"time" "time"
"cloud.google.com/go/auth/credentials"
translate "cloud.google.com/go/translate/apiv3"
"cloud.google.com/go/translate/apiv3/translatepb"
"git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/config"
"git.ma-al.com/goc_daniel/b2b/app/db"
"git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/service/langsService" "git.ma-al.com/goc_daniel/b2b/app/service/langsService"
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
"git.ma-al.com/goc_daniel/b2b/repository/productDescriptionRepo"
"github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/option"
"github.com/openai/openai-go/v3/responses" "github.com/openai/openai-go/v3/responses"
googleopt "google.golang.org/api/option" googleopt "google.golang.org/api/option"
"gorm.io/gorm"
// [START translate_v3_import_client_library]
"cloud.google.com/go/auth/credentials"
translate "cloud.google.com/go/translate/apiv3"
"cloud.google.com/go/translate/apiv3/translatepb"
// [END translate_v3_import_client_library]
) )
type ProductDescriptionService struct { type ProductDescriptionService struct {
db *gorm.DB productDescriptionRepo productDescriptionRepo.UIProductDescriptionRepo
ctx context.Context ctx context.Context
googleCli translate.TranslationClient googleCli translate.TranslationClient
client openai.Client
// projectID is the Google Cloud project ID used as the "parent" in API calls,
// e.g. "projects/my-project-123/locations/global"
projectID string projectID string
openAIClient openai.Client
} }
// New creates a ProductDescriptionService and authenticates against the // New creates a ProductDescriptionService and authenticates against the
@@ -76,31 +70,20 @@ func New() *ProductDescriptionService {
log.Fatalf("productDescriptionService: cannot create Translation client: %v", err) log.Fatalf("productDescriptionService: cannot create Translation client: %v", err)
} }
client := openai.NewClient(option.WithAPIKey("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"), openAIClient := openai.NewClient(option.WithAPIKey("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"),
option.WithHTTPClient(&http.Client{Timeout: 300 * time.Second})) // five minutes timeout option.WithHTTPClient(&http.Client{Timeout: 300 * time.Second})) // five minutes timeout
return &ProductDescriptionService{ return &ProductDescriptionService{
db: db.Get(), productDescriptionRepo: productDescriptionRepo.New(),
ctx: ctx, ctx: ctx,
client: client, openAIClient: openAIClient,
googleCli: *googleCli, googleCli: *googleCli,
projectID: cfg.GoogleTranslate.ProjectID, projectID: cfg.GoogleTranslate.ProjectID,
} }
} }
// We assume that any user has access to all product descriptions
func (s *ProductDescriptionService) GetProductDescription(userID uint, productID uint, productShopID uint, productLangID uint) (*model.ProductDescription, error) { func (s *ProductDescriptionService) GetProductDescription(userID uint, productID uint, productShopID uint, productLangID uint) (*model.ProductDescription, error) {
var ProductDescription model.ProductDescription return s.productDescriptionRepo.GetProductDescription(productID, productShopID, productLangID)
err := s.db.
Table("ps_product_lang").
Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID).
First(&ProductDescription).Error
if err != nil {
return nil, fmt.Errorf("database error: %w", err)
}
return &ProductDescription, nil
} }
// Updates relevant fields with the "updates" map // Updates relevant fields with the "updates" map
@@ -123,37 +106,12 @@ func (s *ProductDescriptionService) SaveProductDescription(userID uint, productI
} }
} }
record := model.ProductDescription{ err := s.productDescriptionRepo.CreateIfDoesNotExist(productID, productShopID, productLangID)
ProductID: productID,
ShopID: productShopID,
LangID: productLangID,
}
err := s.db.
Table("ps_product_lang").
Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID).
FirstOrCreate(&record).Error
if err != nil { if err != nil {
return fmt.Errorf("database error: %w", err) return err
} }
if len(updates) == 0 { return s.productDescriptionRepo.UpdateFields(productID, productShopID, productLangID, updates)
return nil
}
updatesIface := make(map[string]interface{}, len(updates))
for k, v := range updates {
updatesIface[k] = v
}
err = s.db.
Table("ps_product_lang").
Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID).
Updates(updatesIface).Error
if err != nil {
return fmt.Errorf("database error: %w", err)
}
return nil
} }
// TranslateProductDescription fetches the product description for productFromLangID, // TranslateProductDescription fetches the product description for productFromLangID,
@@ -163,16 +121,12 @@ func (s *ProductDescriptionService) SaveProductDescription(userID uint, productI
// The Google Cloud project must have the Cloud Translation API enabled and the // The Google Cloud project must have the Cloud Translation API enabled and the
// service account must hold the "Cloud Translation API User" role. // service account must hold the "Cloud Translation API User" role.
func (s *ProductDescriptionService) TranslateProductDescription(userID uint, productID uint, productShopID uint, productFromLangID uint, productToLangID uint, aiModel string) (*model.ProductDescription, error) { func (s *ProductDescriptionService) TranslateProductDescription(userID uint, productID uint, productShopID uint, productFromLangID uint, productToLangID uint, aiModel string) (*model.ProductDescription, error) {
var ProductDescription model.ProductDescription
err := s.db. productDescription, err := s.productDescriptionRepo.GetProductDescription(productID, productShopID, productFromLangID)
Table("ps_product_lang").
Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productFromLangID).
First(&ProductDescription).Error
if err != nil { if err != nil {
return nil, fmt.Errorf("database error: %w", err) return nil, err
} }
ProductDescription.LangID = productToLangID productDescription.LangID = productToLangID
// we translate all changeable fields, and we keep the exact same HTML structure in relevant fields. // we translate all changeable fields, and we keep the exact same HTML structure in relevant fields.
lang, err := langsService.LangSrv.GetLanguageById(productToLangID) lang, err := langsService.LangSrv.GetLanguageById(productToLangID)
@@ -180,14 +134,14 @@ func (s *ProductDescriptionService) TranslateProductDescription(userID uint, pro
return nil, err return nil, err
} }
fields := []*string{&ProductDescription.Description, fields := []*string{&productDescription.Description,
&ProductDescription.DescriptionShort, &productDescription.DescriptionShort,
&ProductDescription.MetaDescription, &productDescription.MetaDescription,
&ProductDescription.MetaTitle, &productDescription.MetaTitle,
&ProductDescription.Name, &productDescription.Name,
&ProductDescription.AvailableNow, &productDescription.AvailableNow,
&ProductDescription.AvailableLater, &productDescription.AvailableLater,
&ProductDescription.Usage, &productDescription.Usage,
} }
keys := []string{"translation_of_product_description", keys := []string{"translation_of_product_description",
"translation_of_product_short_description", "translation_of_product_short_description",
@@ -213,24 +167,23 @@ func (s *ProductDescriptionService) TranslateProductDescription(userID uint, pro
} }
if aiModel == "OpenAI" { if aiModel == "OpenAI" {
openai_response, _ := s.client.Responses.New(context.Background(), responses.ResponseNewParams{ response, _ := s.openAIClient.Responses.New(context.Background(), responses.ResponseNewParams{
Input: responses.ResponseNewParamsInputUnion{OfString: openai.String(request)}, Input: responses.ResponseNewParamsInputUnion{OfString: openai.String(request)},
Model: openai.ChatModelGPT4_1Mini, Model: openai.ChatModelGPT4_1Mini,
// Model: openai.ChatModelGPT4_1Nano, // Model: openai.ChatModelGPT4_1Nano,
}) })
if openai_response.Status != "completed" { if response.Status != "completed" {
return nil, responseErrors.ErrAIResponseFail return nil, responseErrors.ErrAIResponseFail
} }
response := openai_response.OutputText()
for i := 0; i < len(keys); i++ { for i := 0; i < len(keys); i++ {
success, resolution := resolveResponse(*fields[i], response, keys[i]) success, resolution := resolveResponse(*fields[i], response.OutputText(), keys[i])
if !success { if !success {
return nil, responseErrors.ErrAIBadOutput return nil, responseErrors.ErrAIBadOutput
} }
*fields[i] = resolution *fields[i] = resolution
fmt.Println(resolution) // fmt.Println(resolution)
} }
} else if aiModel == "Google" { } else if aiModel == "Google" {
@@ -253,17 +206,17 @@ func (s *ProductDescriptionService) TranslateProductDescription(userID uint, pro
response := responseGoogle.GetTranslations()[0].GetTranslatedText() response := responseGoogle.GetTranslations()[0].GetTranslatedText()
for i := 0; i < len(keys); i++ { for i := 0; i < len(keys); i++ {
success, match := GetStringInBetween(response, "<"+keys[i]+">", "</"+keys[i]+">") success, match := getStringInBetween(response, "<"+keys[i]+">", "</"+keys[i]+">")
if !success || !isValidXHTML(match) { if !success || !isValidXHTML(match) {
return nil, responseErrors.ErrAIBadOutput return nil, responseErrors.ErrAIBadOutput
} }
*fields[i] = match *fields[i] = match
fmt.Println(match) // fmt.Println(match)
} }
} }
return &ProductDescription, nil return productDescription, nil
} }
func cleanForPrompt(s string) string { func cleanForPrompt(s string) string {
@@ -284,17 +237,17 @@ func cleanForPrompt(s string) string {
switch v := token.(type) { switch v := token.(type) {
case xml.StartElement: case xml.StartElement:
prompt += "<" + AttrName(v.Name) prompt += "<" + attrName(v.Name)
for _, attr := range v.Attr { for _, attr := range v.Attr {
if v.Name.Local == "img" && attr.Name.Local == "alt" { if v.Name.Local == "img" && attr.Name.Local == "alt" {
prompt += fmt.Sprintf(` %s="%s"`, AttrName(attr.Name), attr.Value) prompt += fmt.Sprintf(` %s="%s"`, attrName(attr.Name), attr.Value)
} }
} }
prompt += ">" prompt += ">"
case xml.EndElement: case xml.EndElement:
prompt += "</" + AttrName(v.Name) + ">" prompt += "</" + attrName(v.Name) + ">"
case xml.CharData: case xml.CharData:
prompt += string(v) prompt += string(v)
case xml.Comment: case xml.Comment:
@@ -307,12 +260,12 @@ func cleanForPrompt(s string) string {
} }
func resolveResponse(original string, response string, key string) (bool, string) { func resolveResponse(original string, response string, key string) (bool, string) {
success, match := GetStringInBetween(response, "<"+key+">", "</"+key+">") success, match := getStringInBetween(response, "<"+key+">", "</"+key+">")
if !success || !isValidXHTML(match) { if !success || !isValidXHTML(match) {
return false, "" return false, ""
} }
success, resolution := RebuildFromResponse("<"+key+">"+original+"</"+key+">", "<"+key+">"+match+"</"+key+">") success, resolution := rebuildFromResponse("<"+key+">"+original+"</"+key+">", "<"+key+">"+match+"</"+key+">")
if !success { if !success {
return false, "" return false, ""
} }
@@ -320,8 +273,8 @@ func resolveResponse(original string, response string, key string) (bool, string
return true, resolution[2+len(key) : len(resolution)-3-len(key)] return true, resolution[2+len(key) : len(resolution)-3-len(key)]
} }
// GetStringInBetween returns empty string if no start or end string found // getStringInBetween returns empty string if no start or end string found
func GetStringInBetween(str string, start string, end string) (success bool, result string) { func getStringInBetween(str string, start string, end string) (success bool, result string) {
s := strings.Index(str, start) s := strings.Index(str, start)
if s == -1 { if s == -1 {
return false, "" return false, ""
@@ -358,7 +311,7 @@ func isValidXHTML(s string) bool {
// Rebuilds HTML using the original HTML as a template and the response as a source // Rebuilds HTML using the original HTML as a template and the response as a source
// Assumes that both original and response have the exact same XML structure // Assumes that both original and response have the exact same XML structure
func RebuildFromResponse(s_original string, s_response string) (bool, string) { func rebuildFromResponse(s_original string, s_response string) (bool, string) {
r_original := strings.NewReader(s_original) r_original := strings.NewReader(s_original)
d_original := xml.NewDecoder(r_original) d_original := xml.NewDecoder(r_original)
@@ -397,17 +350,17 @@ func RebuildFromResponse(s_original string, s_response string) (bool, string) {
return false, "" return false, ""
} }
result += "<" + AttrName(v_original.Name) result += "<" + attrName(v_original.Name)
for _, attr := range v_original.Attr { for _, attr := range v_original.Attr {
if v_original.Name.Local != "img" || attr.Name.Local != "alt" { if v_original.Name.Local != "img" || attr.Name.Local != "alt" {
result += fmt.Sprintf(` %s="%s"`, AttrName(attr.Name), attr.Value) result += fmt.Sprintf(` %s="%s"`, attrName(attr.Name), attr.Value)
} }
} }
for _, attr := range v_response.Attr { for _, attr := range v_response.Attr {
if v_response.Name.Local == "img" && attr.Name.Local == "alt" { if v_response.Name.Local == "img" && attr.Name.Local == "alt" {
result += fmt.Sprintf(` %s="%s"`, AttrName(attr.Name), attr.Value) result += fmt.Sprintf(` %s="%s"`, attrName(attr.Name), attr.Value)
} }
} }
result += ">" result += ">"
@@ -429,7 +382,7 @@ func RebuildFromResponse(s_original string, s_response string) (bool, string) {
} }
if v_original.Name.Local != "img" { if v_original.Name.Local != "img" {
result += "</" + AttrName(v_original.Name) + ">" result += "</" + attrName(v_original.Name) + ">"
} }
case xml.CharData: case xml.CharData:
@@ -485,7 +438,7 @@ func RebuildFromResponse(s_original string, s_response string) (bool, string) {
} }
} }
func AttrName(name xml.Name) string { func attrName(name xml.Name) string {
if name.Space == "" { if name.Space == "" {
return name.Local return name.Local
} else { } else {

View File

@@ -1,54 +0,0 @@
package pagination
import (
"gorm.io/gorm"
)
type Paging struct {
Page uint `json:"page_number" example:"5"`
Elements uint `json:"elements_per_page" example:"30"`
}
func (p Paging) Offset() int {
return int(p.Elements) * int(p.Page-1)
}
func (p Paging) Limit() int {
return int(p.Elements)
}
type Found[T any] struct {
Items []T `json:"items,omitempty"`
Count uint `json:"items_count" example:"56"`
}
func Paginate[T any](paging Paging, stmt *gorm.DB) (Found[T], error) {
var items []T
var count int64
base := stmt.Session(&gorm.Session{})
countDB := stmt.Session(&gorm.Session{
NewDB: true, // critical: do NOT reuse statement
})
if err := countDB.
Table("(?) as sub", base).
Count(&count).Error; err != nil {
return Found[T]{}, err
}
err := base.
Offset(paging.Offset()).
Limit(paging.Limit()).
Find(&items).
Error
if err != nil {
return Found[T]{}, err
}
return Found[T]{
Items: items,
Count: uint(count),
}, err
}

View File

@@ -0,0 +1,150 @@
package filters
import (
"fmt"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
type FilterFunction = func(*gorm.DB) *gorm.DB
func Where(statement string, args ...interface{}) Filter {
filt := func(db *gorm.DB) *gorm.DB {
return db.Where(statement, args...)
}
return Filter{
category: WHERE_FILTER,
filter: filt,
}
}
func StructToWhereScope[T any](model T) Filter {
filt := func(db *gorm.DB) *gorm.DB {
return db.Where(model)
}
return Filter{
category: WHERE_FILTER,
filter: filt,
}
}
func Order(field string, desc bool) Filter {
var filt FilterFunction
if desc {
filt = func(d *gorm.DB) *gorm.DB {
return d.Order(field + " DESC")
}
} else {
filt = func(d *gorm.DB) *gorm.DB {
return d.Order(field)
}
}
return Filter{
category: ORDER_FILTER,
filter: filt,
}
}
func WhereFromStrings(column, conditionOperator, value string) Filter {
var filt func(*gorm.DB) *gorm.DB
if strings.HasPrefix(value, "~") {
value = strings.ReplaceAll(value, "~", "")
filt = func(d *gorm.DB) *gorm.DB {
return d.Where("lower("+column+`) LIKE lower(?)`, "%"+value+"%")
}
return Filter{
category: LIKE_FILTER,
filter: filt,
}
}
if strings.Contains(value, "]") && strings.Contains(value, "[") {
period := strings.ReplaceAll(value, "[", "")
period = strings.ReplaceAll(period, "]", "")
vals := strings.Split(period, ",")
if len(vals) == 2 {
from, errA := time.Parse("2006-01-02", vals[0])
to, errB := time.Parse("2006-01-02", vals[1])
if errA == nil && errB == nil {
filt = func(d *gorm.DB) *gorm.DB {
return d.Where(column+` BETWEEN ? AND ?`, from.Format("2006-01-02"), to.Format("2006-01-02"))
}
return Filter{
category: WHERE_FILTER,
filter: filt,
}
} else {
filt = func(d *gorm.DB) *gorm.DB {
return d.Where(column+` BETWEEN ? AND ?`, vals[0], vals[1])
}
return Filter{
category: WHERE_FILTER,
filter: filt,
}
}
}
}
if conditionOperator == "LIKE" {
value = fmt.Sprintf("%%%s%%", value)
}
// in future add more grouping functions
if strings.Contains(strings.ToLower(column), "count(") {
filt = func(d *gorm.DB) *gorm.DB {
return d.Having(column+` `+conditionOperator+` ?`, value)
}
return Filter{
category: WHERE_FILTER,
filter: filt,
}
}
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
filt = func(d *gorm.DB) *gorm.DB {
return d.Where(column+` `+conditionOperator+` ?`, i)
}
return Filter{
category: WHERE_FILTER,
filter: filt,
}
}
if f, err := strconv.ParseFloat(value, 64); err == nil {
filt = func(d *gorm.DB) *gorm.DB {
return d.Where(column+` `+conditionOperator+` ?`, f)
}
return Filter{
category: WHERE_FILTER,
filter: filt,
}
}
if b, err := strconv.ParseBool(value); err == nil {
filt = func(d *gorm.DB) *gorm.DB {
return d.Where(column+` `+conditionOperator+` ?`, b)
}
return Filter{
category: WHERE_FILTER,
filter: filt,
}
}
filt = func(d *gorm.DB) *gorm.DB {
return d.Where(column+` `+conditionOperator+` ?`, value)
}
return Filter{
category: WHERE_FILTER,
filter: filt,
}
}

View File

@@ -0,0 +1,107 @@
package filters
import (
"fmt"
"github.com/samber/lo"
"gorm.io/gorm"
)
// Use one of declared in the package constants to instantiate the type.
type filterCategory = string
// Enumaration of known types of filters. The assumption is that all filters
// belonging to a single category (type) can be used together at a particular
// step in the query process.
const (
// Should be safe to use at any step of longer query series to reduce the
// number of results. If it is not, choose a different filter type
WHERE_FILTER filterCategory = "where"
// An like filter
LIKE_FILTER filterCategory = "where"
// An order by clause which can be used at any final step of a complex query
// to change the order of results.
ORDER_FILTER filterCategory = "order"
// TODO: document the special case of filters on products
FEAT_VAL_PRODUCT_FILTER filterCategory = "featval_product"
)
type Filter struct {
category filterCategory
filter func(*gorm.DB) *gorm.DB
}
func NewFilter(category filterCategory, filter func(*gorm.DB) *gorm.DB) Filter {
return Filter{
category: category,
filter: filter,
}
}
type FiltersList struct {
filters []Filter
}
func NewFiltersList() FiltersList {
return FiltersList{
// we allocate some extra space beforehand to reduce the overhead of resizing
filters: make([]Filter, 0, 3),
}
}
func NewListWithFilter(filt Filter) FiltersList {
l := NewFiltersList()
l.filters = append(l.filters, filt)
return l
}
func (f *FiltersList) NewFilter(category filterCategory, filter func(*gorm.DB) *gorm.DB) {
f.filters = append(f.filters, NewFilter(category, filter))
}
func (f *FiltersList) Append(filter ...Filter) {
f.filters = append(f.filters, filter...)
}
// Return all stored filters as []func(*gorm.DB)*gorm.DB
func (f *FiltersList) All() []func(*gorm.DB) *gorm.DB {
return lo.Map(f.filters, func(filt Filter, _ int) func(*gorm.DB) *gorm.DB {
return filt.filter
})
}
func (f *FiltersList) OfCategory(cat filterCategory) []func(*gorm.DB) *gorm.DB {
return lo.Map(lo.Filter(f.filters, func(v Filter, _ int) bool {
return v.category == cat
}), func(el Filter, _ int) func(*gorm.DB) *gorm.DB {
return el.filter
})
}
func (f *FiltersList) ApplyAll(d *gorm.DB) {
d.Scopes(f.All()...)
}
func (f *FiltersList) Apply(d *gorm.DB, cat filterCategory) {
d.Scopes(f.OfCategory(cat)...)
}
func (f *FiltersList) Merge(another FiltersList) {
f.filters = append(f.filters, another.filters...)
}
// An implementation of stringer on FiltersList that is meant rather to be used
// for debug display
func (f FiltersList) String() string {
groupMap := lo.GroupBy(f.filters, func(t Filter) string {
return t.category
})
res := "FiltersList{"
for key := range groupMap {
res += fmt.Sprintf(" \"%s\": %d filters", key, len(groupMap[key]))
}
res += " }"
return res
}

View File

@@ -0,0 +1,159 @@
package find
import (
"errors"
"reflect"
"strings"
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
"gorm.io/gorm"
)
type Paging struct {
Page uint `json:"page_number" example:"5"`
Elements uint `json:"elements_per_page" example:"30"`
}
func (p Paging) Offset() int {
return int(p.Elements) * int(p.Page-1)
}
func (p Paging) Limit() int {
return int(p.Elements)
}
type Found[T any] struct {
Items []T `json:"items,omitempty"`
Count uint `json:"items_count" example:"56"`
Spec map[string]interface{} `json:"spec,omitempty"`
}
// Wraps given query adding limit, offset clauses and SQL_CALC_FOUND_ROWS to it
// and running SELECT FOUND_ROWS() afterwards to fetch the total number
// (ignoring LIMIT) of results. The final results are wrapped into the
// [find.Found] type.
func Paginate[T any](langID uint, paging Paging, stmt *gorm.DB) (Found[T], error) {
var items []T
var count uint64
// stmt.Debug()
err := stmt.
Clauses(SqlCalcFound()).
Offset(paging.Offset()).
Limit(paging.Limit()).
Find(&items).
Error
if err != nil {
return Found[T]{}, err
}
countInterface, ok := stmt.Get(FOUND_ROWS_CTX_KEY)
if !ok {
return Found[T]{}, errors.New(FOUND_ROWS_CTX_KEY + " value was not found in the gorm db context")
}
if count, ok = countInterface.(uint64); !ok {
return Found[T]{}, errors.New("failed to cast value under " + FOUND_ROWS_CTX_KEY + " to uint64")
}
columnsSpec := GetColumnsSpec[T](langID)
return Found[T]{
Items: items,
Count: uint(count),
Spec: map[string]interface{}{
"columns": columnsSpec,
},
}, err
}
// GetColumnsSpec[T any] generates a column specification map for a given struct type T.
// Each key is the JSON property name, and the value is a map containing:
// - "filter_type": suggested filter type based on field type or `filt` tag
// - To disable filtering for a field, set `filt:"none"` in the struct tag
// - "sortable": currently hardcoded to true
// - "order": order of fields as they appear
//
// Returns nil if T is not a struct.
func GetColumnsSpec[T any](langID uint) map[string]map[string]interface{} {
result := make(map[string]map[string]interface{})
typ := reflect.TypeOf((*T)(nil)).Elem()
if typ.Kind() != reflect.Struct {
return nil
}
order := 1
processStructFields(langID, typ, result, &order)
return result
}
type FilterType string
const (
FilterTypeRange FilterType = "range"
FilterTypeTimerange FilterType = "timerange"
FilterTypeLike FilterType = "like"
FilterTypeSwitch FilterType = "switch"
FilterTypeNone FilterType = "none"
)
func isValidFilterType(ft string) bool {
switch FilterType(ft) {
case FilterTypeRange, FilterTypeTimerange, FilterTypeLike, FilterTypeSwitch:
return true
default:
return false
}
}
// processStructFields recursively processes struct fields to populate the result map.
// It handles inline structs, reads `json` and `filt` tags, and determines filter types
// based on the field type when `filt` tag is absent.
// `order` is incremented for each field to track field ordering.
func processStructFields(langID uint, typ reflect.Type, result map[string]map[string]interface{}, order *int) {
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
propName := strings.Split(jsonTag, ",")[0]
if propName == "" {
propName = field.Name
}
if strings.Contains(jsonTag, ",inline") && field.Type.Kind() == reflect.Struct {
processStructFields(langID, field.Type, result, order)
continue
}
filterType := field.Tag.Get("filt")
if filterType != "" {
if !isValidFilterType(filterType) {
filterType = string(FilterTypeNone)
}
} else {
fieldType := field.Type.String()
switch {
case strings.HasPrefix(fieldType, "int"), strings.HasPrefix(fieldType, "uint"), strings.HasPrefix(fieldType, "float"), strings.HasPrefix(fieldType, "decimal.Decimal"):
filterType = string(FilterTypeRange)
case strings.Contains(fieldType, "Time"):
filterType = string(FilterTypeTimerange)
case fieldType == "string":
filterType = string(FilterTypeLike)
case fieldType == "bool":
filterType = string(FilterTypeSwitch)
default:
filterType = string(FilterTypeNone)
}
}
result[propName] = map[string]interface{}{
"filter_type": filterType,
"sortable": func() bool { val, ok := field.Tag.Lookup("sortable"); return !ok || val == "true" }(),
"order": *order,
"title": i18n.T___(langID, field.Tag.Get("title")),
"display": func() bool { val, ok := field.Tag.Lookup("display"); return !ok || val == "true" }(),
"hidden": field.Tag.Get("hidden") == "true",
}
*order++
}
}

View File

@@ -0,0 +1,46 @@
package find
import (
"errors"
"gorm.io/gorm"
)
const (
// Key under which result of `SELECT FOUND_ROWS()` should be stored in the
// driver context.
FOUND_ROWS_CTX_KEY = "maal:found_rows"
// Suggested name under which [find.FoundRowsCallback] can be registered.
FOUND_ROWS_CALLBACK = "maal:found_rows"
)
// Searches query clauses for presence of `SQL_CALC_FOUND_ROWS` and runs `SELECT
// FOUND_ROWS();` right after the query containing such clause. The result is
// put in the driver context under key [find.FOUND_ROWS_CTX_KEY]. For the
// callback to work correctly it must be registered and executed before the
// `gorm:preload` callback.
func FoundRowsCallback(d *gorm.DB) {
if _, ok := d.Statement.Clauses["SELECT"].AfterNameExpression.(sqlCalcFound); ok {
var count uint64
sqlDB, err := d.DB()
if err != nil {
_ = d.AddError(err)
return
}
res := sqlDB.QueryRowContext(d.Statement.Context, "SELECT FOUND_ROWS();")
if res == nil {
_ = d.AddError(errors.New(`fialed to issue SELECT FOUND_ROWS() query`))
return
}
if res.Err() != nil {
_ = d.AddError(res.Err())
return
}
err = res.Scan(&count)
if err != nil {
_ = d.AddError(err)
return
}
d.Set(FOUND_ROWS_CTX_KEY, count)
}
}

View File

@@ -0,0 +1,51 @@
package find
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type sqlCalcFound struct{}
// Creates a new Clause which adds `SQL_CALC_FOUND_ROWS` right after `SELECT`.
// If [find.FoundRowsCallback] is registered the presence of this clause will
// cause `FOUND_ROWS()` result to be available in the driver context.
func SqlCalcFound() sqlCalcFound {
return sqlCalcFound{}
}
// Implements gorm's [clause.Clause]
func (sqlCalcFound) Name() string {
return "SQL_CALC_FOUND_ROWS"
}
// Implements gorm's [clause.Clause]
func (sqlCalcFound) Build(builder clause.Builder) {
_, _ = builder.WriteString("SQL_CALC_FOUND_ROWS")
}
// Implements gorm's [clause.Clause]
func (sqlCalcFound) MergeClause(cl *clause.Clause) {
}
// Implements [gorm.StatementModifier]
func (calc sqlCalcFound) ModifyStatement(stmt *gorm.Statement) {
selectClause := stmt.Clauses["SELECT"]
if selectClause.AfterNameExpression == nil {
selectClause.AfterNameExpression = calc
} else if _, ok := selectClause.AfterNameExpression.(sqlCalcFound); !ok {
selectClause.AfterNameExpression = exprs{selectClause.AfterNameExpression, calc}
}
stmt.Clauses["SELECT"] = selectClause
}
type exprs []clause.Expression
func (exprs exprs) Build(builder clause.Builder) {
for idx, expr := range exprs {
if idx > 0 {
_ = builder.WriteByte(' ')
}
expr.Build(builder)
}
}

View File

@@ -0,0 +1,43 @@
package query_params
import (
"fmt"
"reflect"
"strings"
mreflect "git.ma-al.com/goc_daniel/b2b/app/utils/reflect"
)
// MapParamsKeyToDbColumn will attempt to map provided key into unique (prefixed
// with table) column name. It will do so using following priority of sources of
// mapping:
// 1. `formColumnMapping` argument. If the mapped values contain a dot, the part
// before the dot will be used for the table name. Otherwise the table name will
// be derived from the generic parameter `T`.
// 2. json tags of provided as generic `T` struct. The table name will be also
// derived from the generic if not provided as dot prefix.
func MapParamsKeyToDbColumn[DEFAULT_TABLE_MODEL any](key string, mapping ...map[string]string) (string, error) {
ERR := "Failed to find appropiate mapping from form field to database column for key: '%s', and default table name: '%s'"
if len(mapping) > 0 {
if field, ok := (mapping[0])[key]; ok {
return field, nil
}
} else {
var t DEFAULT_TABLE_MODEL
if table, field, ok := strings.Cut(key, "."); ok {
if column, err := mreflect.GetGormColumnFromJsonField(field, reflect.TypeOf(t)); err == nil {
return table + "." + column, nil
}
return "", fmt.Errorf(ERR, key, table)
} else {
table := mreflect.GetTableName[DEFAULT_TABLE_MODEL]()
if column, err := mreflect.GetGormColumnFromJsonField(key, reflect.TypeOf(t)); err == nil {
return table + "." + column, nil
} else {
return "", fmt.Errorf(ERR, key, table)
}
}
}
return "", fmt.Errorf(ERR, key, mreflect.GetTableName[DEFAULT_TABLE_MODEL]())
}

View File

@@ -0,0 +1,63 @@
package query_params
import (
"strconv"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/filters"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/find"
"github.com/gofiber/fiber/v3"
)
var FunctionalQueryParams = []string{
// Used to specidy order of results
"sort",
// Used to specify page of search resulst
"p",
// Used to specify number of elements on a page
"elems",
// Used to specify allowed values of features on products
"values",
}
func ParseFilters[T any](c fiber.Ctx, formColumnMappimg ...map[string]string) (find.Paging, *filters.FiltersList, error) {
// field/column based filters
filters, err := ParseFieldFilters[T](c, formColumnMappimg...)
if err != nil {
return find.Paging{}, filters, err
}
// pagination
pageNum, pageSize := ParsePagination(c)
// ret
return find.Paging{Page: pageNum, Elements: pageSize}, filters, nil
}
// Parse field related filters from params query. Produces where clauses and
// order rules.
func ParseFieldFilters[T any](c fiber.Ctx, formColumnMapping ...map[string]string) (*filters.FiltersList, error) {
// var model T
list := filters.NewFiltersList()
whereScopefilters := ParseWhereScopes[T](c, []string{}, formColumnMapping...)
list.Append(whereScopefilters...)
ord, err := ParseOrdering[T](c, formColumnMapping...)
if err != nil {
return &list, err
}
// addDefaultOrderingIfNeeded(&ord, model)
for i := range ord {
if err == nil {
list.Append(filters.Order(ord[i].Column, ord[i].IsDesc))
}
}
return &list, nil
}
// TODO: Add some source of defaults for pagination size here
func ParsePagination(c fiber.Ctx) (uint, uint) {
pageNum, _ := strconv.ParseInt(c.Query("p", "1"), 10, 64)
pageSize, _ := strconv.ParseInt(c.Query("elems", "30"), 10, 64)
return uint(pageNum), uint(pageSize)
}

View File

@@ -0,0 +1,82 @@
package query_params
import (
"strings"
"github.com/gofiber/fiber/v3"
)
type Ordering struct {
Column string
IsDesc bool
}
func ParseOrdering[T any](c fiber.Ctx, columnMapping ...map[string]string) ([]Ordering, error) {
param := c.Query("sort")
if len(param) < 1 {
return []Ordering{}, nil
}
rules := strings.Split(param, ";")
var orderings []Ordering
for _, r := range rules {
ord, err := parseOrderingRule[T](r, columnMapping...)
if err != nil {
return orderings, err
}
orderings = append(orderings, ord)
}
return orderings, nil
}
func parseOrderingRule[T any](rule string, columnMapping ...map[string]string) (Ordering, error) {
var desc bool
if key, descStr, ok := strings.Cut(rule, ","); ok {
switch {
case strings.Compare(descStr, "desc") == 0:
desc = true
case strings.Compare(descStr, "asc") == 0:
desc = false
default:
desc = true
}
if col, err := MapParamsKeyToDbColumn[T](key, columnMapping...); err == nil {
return Ordering{
Column: col,
IsDesc: desc,
}, nil
} else {
return Ordering{}, err
}
} else {
if col, err := MapParamsKeyToDbColumn[T](key, columnMapping...); err == nil {
return Ordering{
Column: col,
IsDesc: true,
}, nil
} else {
return Ordering{}, err
}
}
}
// func addDefaultOrderingIfNeeded[T any](previousOrderings *[]Ordering, model T) {
// newOrderings := new([]Ordering)
// var t T
// if len(*previousOrderings) < 1 {
// if col, err := mreflect.GetGormColumnFromJsonField("id", reflect.TypeOf(t)); err == nil {
// *newOrderings = append(*newOrderings, Ordering{
// Column: mreflect.GetTableName[T]() + "." + col,
// IsDesc: true,
// })
// }
// if col, err := mreflect.GetGormColumnFromJsonField("iso_code", reflect.TypeOf(t)); err == nil {
// *newOrderings = append(*newOrderings, Ordering{
// Column: mreflect.GetTableName[T]() + "." + col,
// IsDesc: false,
// })
// }
// *newOrderings = append(*newOrderings, *previousOrderings...)
// *previousOrderings = *newOrderings
// }
// }

View File

@@ -0,0 +1,75 @@
package query_params
import (
"strings"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/filters"
"github.com/gofiber/fiber/v3"
)
// ParseWhereScopes will attempt to create where scope query filters from url
// query params. It will map form fields to a database column name using
// `MapParamsKeyToDbColumn` function.
func ParseWhereScopes[T any](c fiber.Ctx, ignoredKeys []string, formColumnMapping ...map[string]string) []filters.Filter {
var parsedFilters []filters.Filter
//nolint
for key, value := range c.Request().URI().QueryArgs().All() {
keyStr := string(key)
valStr := string(value)
isIgnored := false
for _, ignoredKey := range ignoredKeys {
if keyStr == ignoredKey {
isIgnored = true
break
}
}
if isIgnored {
continue
}
baseKey, operator := extractOperator(keyStr)
if col, err := MapParamsKeyToDbColumn[T](baseKey, formColumnMapping...); err == nil {
if strings.HasPrefix(valStr, "~") {
parsedFilters = append(parsedFilters, filters.WhereFromStrings(col, "LIKE", valStr))
continue
}
op := resolveOperator(operator)
parsedFilters = append(parsedFilters, filters.WhereFromStrings(col, op, valStr))
}
}
return parsedFilters
}
func extractOperator(key string) (base string, operatorSuffix string) {
suffixes := []string{"_gt", "_gte", "_lt", "_lte", "_eq", "_neq"}
for _, suf := range suffixes {
if strings.HasSuffix(key, suf) {
return strings.TrimSuffix(key, suf), suf[1:]
}
}
return key, ""
}
func resolveOperator(suffix string) string {
switch suffix {
case "gt":
return ">"
case "gte":
return ">="
case "lt":
return "<"
case "lte":
return "<="
case "neq":
return "!="
case "eq":
return "="
default:
return "LIKE"
}
}

View File

@@ -0,0 +1,37 @@
package queryparser
import (
"regexp"
"strconv"
"strings"
"github.com/gofiber/fiber/v3"
)
func ParseQuery(c fiber.Ctx) map[string]interface{} {
queryParams := map[string]interface{}{}
re := regexp.MustCompile(`\?(\w.+)$`)
xx := re.FindAllStringSubmatch(c.Request().URI().String(), -1)
if len(xx) > 0 {
if len(xx[0]) == 2 {
queryParts := strings.Split(xx[0][1], "&")
for _, q := range queryParts {
qq := strings.Split(q, "=")
if len(qq) == 2 {
if num, err := strconv.ParseInt(qq[1], 10, 64); err == nil {
queryParams[qq[0]] = num
} else if float, err := strconv.ParseFloat(qq[1], 64); err == nil {
queryParams[qq[0]] = float
} else {
queryParams[qq[0]] = qq[1]
}
} else {
queryParams[qq[0]] = true
}
}
}
}
return queryParams
}

View File

@@ -0,0 +1,90 @@
package reflect
import (
"fmt"
"reflect"
"regexp"
"strings"
"git.ma-al.com/goc_daniel/b2b/app/db"
)
// TODO: instead of matching with string.Contains use something less error-prone
func checkIfContainsJSON(i int, t reflect.Type, name string) string {
if wholeTag, ok := t.Field(i).Tag.Lookup("json"); ok {
tags := strings.Split(wholeTag, ",")
for _, tag := range tags {
if name == strings.TrimSpace(tag) {
return db.DB.NamingStrategy.ColumnName(t.Name(), t.Field(i).Name)
}
}
}
return ""
}
// Not tail recursive but should do fine. Goes only as deep as the hierarchy of
// inlined structs.
// TODO: improve used internally checkIfContainsJSON
func GetGormColumnFromJsonField(jsonName string, t reflect.Type) (string, error) {
var res string
for i := range make([]bool, t.NumField()) {
if tag, ok := t.Field(i).Tag.Lookup("json"); ok && strings.Contains(tag, "inline") {
var err error
res, err = GetGormColumnFromJsonField(jsonName, t.Field(i).Type)
if err != nil {
return "", fmt.Errorf("no field of struct %q has a name %q in its json form", t.Name(), jsonName)
}
} else {
res = checkIfContainsJSON(i, t, jsonName)
}
if res != "" {
return res, nil
}
}
return "", fmt.Errorf("no field of struct %q has a name %q in its json form", t.Name(), jsonName)
}
func GetTableName[T any]() string {
var model T
typ := reflect.TypeOf(model).Name()
return db.DB.NamingStrategy.TableName(typ)
}
func GetParamFromFieldTag[T any](object T, fieldname string, tagname string, paramname string) string {
if table, ok := reflect.TypeOf(object).FieldByName(fieldname); ok {
if t, ok := table.Tag.Lookup(tagname); ok {
if paramname == "" {
return t
}
re := regexp.MustCompile(`(?m)` + paramname + `:(\w*)`)
f := re.FindAllStringSubmatch(t, -1)
if len(re.FindAllStringSubmatch(t, -1)) > 0 {
return f[0][1]
}
}
}
return ""
}
func GetPrimaryKey[T any](item T) string {
var search func(T) string = func(item T) string {
val := reflect.ValueOf(item)
typ := reflect.TypeOf(item)
for i := 0; i < val.NumField(); i++ {
if gg, ok := typ.Field(i).Tag.Lookup("gorm"); ok {
xx := strings.Split(gg, ";")
for _, t := range xx {
if strings.HasPrefix(strings.ToLower(t), "primarykey") {
return db.DB.NamingStrategy.TableName(typ.Field(i).Name)
}
}
}
if val.Field(i).Type().String() == "db.Model" {
return db.DB.NamingStrategy.TableName("ID")
}
}
return ""
}
return search(item)
}

View File

@@ -1,15 +1,12 @@
package response package response
import "github.com/gofiber/fiber/v3"
type Response[T any] struct { type Response[T any] struct {
Message string `json:"message,omitempty"` Message string `json:"message"`
Items *T `json:"items,omitempty"` Items *T `json:"items"`
Count *int `json:"count,omitempty"` Count int `json:"count"`
} }
func Make[T any](c fiber.Ctx, status int, items *T, count *int, message string) Response[T] { func Make[T any](items *T, count int, message string) Response[T] {
c.Status(status)
return Response[T]{ return Response[T]{
Message: message, Message: message,
Items: items, Items: items,

View File

@@ -25,6 +25,8 @@ var (
ErrEmailRequired = errors.New("email is required") ErrEmailRequired = errors.New("email is required")
ErrEmailPasswordRequired = errors.New("email and password are required") ErrEmailPasswordRequired = errors.New("email and password are required")
ErrRefreshTokenRequired = errors.New("refresh token is required") ErrRefreshTokenRequired = errors.New("refresh token is required")
ErrBadLangID = errors.New("bad language id")
ErrBadCountryID = errors.New("bad country id")
// Typed errors for password reset // Typed errors for password reset
ErrInvalidResetToken = errors.New("invalid reset token") ErrInvalidResetToken = errors.New("invalid reset token")
@@ -38,11 +40,14 @@ var (
ErrVerificationTokenExpired = errors.New("verification token has expired") ErrVerificationTokenExpired = errors.New("verification token has expired")
// Typed errors for product description handler // Typed errors for product description handler
ErrBadAttribute = errors.New("bad attribute") ErrBadAttribute = errors.New("bad or missing attribute value in header")
ErrBadField = errors.New("this field can not be updated") ErrBadField = errors.New("this field can not be updated")
ErrInvalidXHTML = errors.New("text is not in xhtml format") ErrInvalidXHTML = errors.New("text is not in xhtml format")
ErrAIResponseFail = errors.New("AI responded with failure") ErrAIResponseFail = errors.New("AI responded with failure")
ErrAIBadOutput = errors.New("AI response does not obey the format") ErrAIBadOutput = errors.New("AI response does not obey the format")
// Typed errors for product list handler
ErrBadPaging = errors.New("bad or missing paging attribute value in header")
) )
// Error represents an error with HTTP status code // Error represents an error with HTTP status code
@@ -95,6 +100,10 @@ func GetErrorCode(c fiber.Ctx, err error) string {
return i18n.T_(c, "error.err_token_required") return i18n.T_(c, "error.err_token_required")
case errors.Is(err, ErrRefreshTokenRequired): case errors.Is(err, ErrRefreshTokenRequired):
return i18n.T_(c, "error.err_refresh_token_required") return i18n.T_(c, "error.err_refresh_token_required")
case errors.Is(err, ErrBadLangID):
return i18n.T_(c, "error.err_bad_lang_id")
case errors.Is(err, ErrBadCountryID):
return i18n.T_(c, "error.err_bad_country_id")
case errors.Is(err, ErrInvalidResetToken): case errors.Is(err, ErrInvalidResetToken):
return i18n.T_(c, "error.err_invalid_reset_token") return i18n.T_(c, "error.err_invalid_reset_token")
@@ -119,9 +128,12 @@ func GetErrorCode(c fiber.Ctx, err error) string {
case errors.Is(err, ErrInvalidXHTML): case errors.Is(err, ErrInvalidXHTML):
return i18n.T_(c, "error.err_invalid_html") return i18n.T_(c, "error.err_invalid_html")
case errors.Is(err, ErrAIResponseFail): case errors.Is(err, ErrAIResponseFail):
return i18n.T_(c, "error.err_openai_response_fail") return i18n.T_(c, "error.err_ai_response_fail")
case errors.Is(err, ErrAIBadOutput): case errors.Is(err, ErrAIBadOutput):
return i18n.T_(c, "error.err_openai_bad_output") return i18n.T_(c, "error.err_ai_bad_output")
case errors.Is(err, ErrBadPaging):
return i18n.T_(c, "error.err_bad_paging")
default: default:
return i18n.T_(c, "error.err_internal_server_error") return i18n.T_(c, "error.err_internal_server_error")
@@ -145,6 +157,8 @@ func GetErrorStatus(err error) int {
errors.Is(err, ErrEmailPasswordRequired), errors.Is(err, ErrEmailPasswordRequired),
errors.Is(err, ErrTokenRequired), errors.Is(err, ErrTokenRequired),
errors.Is(err, ErrRefreshTokenRequired), errors.Is(err, ErrRefreshTokenRequired),
errors.Is(err, ErrBadLangID),
errors.Is(err, ErrBadCountryID),
errors.Is(err, ErrPasswordsDoNotMatch), errors.Is(err, ErrPasswordsDoNotMatch),
errors.Is(err, ErrTokenPasswordRequired), errors.Is(err, ErrTokenPasswordRequired),
errors.Is(err, ErrInvalidResetToken), errors.Is(err, ErrInvalidResetToken),
@@ -154,7 +168,8 @@ func GetErrorStatus(err error) int {
errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrInvalidPassword),
errors.Is(err, ErrBadAttribute), errors.Is(err, ErrBadAttribute),
errors.Is(err, ErrBadField), errors.Is(err, ErrBadField),
errors.Is(err, ErrInvalidXHTML): errors.Is(err, ErrInvalidXHTML),
errors.Is(err, ErrBadPaging):
return fiber.StatusBadRequest return fiber.StatusBadRequest
case errors.Is(err, ErrEmailExists): case errors.Is(err, ErrEmailExists):
return fiber.StatusConflict return fiber.StatusConflict

3
bo/components.d.ts vendored
View File

@@ -17,15 +17,14 @@ declare module 'vue' {
En_TermsAndConditionsView: typeof import('./src/components/terms/en_TermsAndConditionsView.vue')['default'] En_TermsAndConditionsView: typeof import('./src/components/terms/en_TermsAndConditionsView.vue')['default']
LangSwitch: typeof import('./src/components/inner/langSwitch.vue')['default'] LangSwitch: typeof import('./src/components/inner/langSwitch.vue')['default']
PageAddresses: typeof import('./src/components/customer/PageAddresses.vue')['default'] PageAddresses: typeof import('./src/components/customer/PageAddresses.vue')['default']
PageCart: typeof import('./src/components/customer/PageCart.vue')['default']
PageProductCardFull: typeof import('./src/components/customer/PageProductCardFull.vue')['default'] PageProductCardFull: typeof import('./src/components/customer/PageProductCardFull.vue')['default']
Pl_PrivacyPolicyView: typeof import('./src/components/terms/pl_PrivacyPolicyView.vue')['default'] Pl_PrivacyPolicyView: typeof import('./src/components/terms/pl_PrivacyPolicyView.vue')['default']
Pl_TermsAndConditionsView: typeof import('./src/components/terms/pl_TermsAndConditionsView.vue')['default'] Pl_TermsAndConditionsView: typeof import('./src/components/terms/pl_TermsAndConditionsView.vue')['default']
ProductCardFull: typeof import('./src/components/customer/ProductCardFull.vue')['default']
ProductCustomization: typeof import('./src/components/customer/components/ProductCustomization.vue')['default'] ProductCustomization: typeof import('./src/components/customer/components/ProductCustomization.vue')['default']
ProductDetailView: typeof import('./src/components/admin/ProductDetailView.vue')['default'] ProductDetailView: typeof import('./src/components/admin/ProductDetailView.vue')['default']
ProductsView: typeof import('./src/components/admin/ProductsView.vue')['default'] ProductsView: typeof import('./src/components/admin/ProductsView.vue')['default']
ProductVariants: typeof import('./src/components/customer/components/ProductVariants.vue')['default'] ProductVariants: typeof import('./src/components/customer/components/ProductVariants.vue')['default']
'ProductСustomization': typeof import('./src/components/customer/components/ProductСustomization.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
ThemeSwitch: typeof import('./src/components/inner/themeSwitch.vue')['default'] ThemeSwitch: typeof import('./src/components/inner/themeSwitch.vue')['default']

View File

@@ -2,10 +2,6 @@ import type { NuxtUIOptions } from '@nuxt/ui/unplugin'
export const uiOptions: NuxtUIOptions = { export const uiOptions: NuxtUIOptions = {
ui: { ui: {
colors: {
primary: 'blue',
neutral: 'zink',
},
pagination: { pagination: {
slots: { slots: {
root: '', root: '',
@@ -56,6 +52,12 @@ export const uiOptions: NuxtUIOptions = {
tr: 'border-b! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! text-(--black)! dark:text-white!', tr: 'border-b! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! text-(--black)! dark:text-white!',
} }
},
modal: {
slots: {
content: 'border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! bg-(--second-light) dark:bg-(--main-dark)',
}
} }
} }
} }

View File

@@ -9,7 +9,7 @@ const authStore = useAuthStore()
<template> <template>
<header <header
class="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-(--black) backdrop-blur-md border-b border-(--border-light) dark:border-(--border-dark)"> class="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-(--black) backdrop-blur-md border-b border-(--border-light) dark:border-(--border-dark)">
<div class="container px-4 sm:px-6 lg:px-8"> <div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14"> <div class="flex items-center justify-between h-14">
<!-- Logo --> <!-- Logo -->
<RouterLink :to="{ name: 'home' }" class="flex items-center gap-2"> <RouterLink :to="{ name: 'home' }" class="flex items-center gap-2">
@@ -28,6 +28,9 @@ const authStore = useAuthStore()
<RouterLink :to="{ name: 'addresses' }"> <RouterLink :to="{ name: 'addresses' }">
Addresses Addresses
</RouterLink> </RouterLink>
<RouterLink :to="{ name: 'cart' }">
Cart
</RouterLink>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Language Switcher --> <!-- Language Switcher -->
<LangSwitch /> <LangSwitch />

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import ProductsView from '@/views/customer/ProductsView.vue';
import LangSwitch from './inner/langSwitch.vue' import LangSwitch from './inner/langSwitch.vue'
import ThemeSwitch from './inner/themeSwitch.vue' import ThemeSwitch from './inner/themeSwitch.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="container my-10 "> <div class="container my-10 mx-auto ">
<div <div
class="flex items-end justify-between gap-4 mb-6 bg-(--second-light) dark:bg-(--main-dark) border border-(--border-light) dark:border-(--border-dark) p-4 rounded-md"> class="flex items-end justify-between gap-4 mb-6 bg-(--second-light) dark:bg-(--main-dark) border border-(--border-light) dark:border-(--border-dark) p-4 rounded-md">

View File

@@ -1,118 +1,153 @@
<template> <template>
<div class="container"> <div class="container mx-auto mt-10">
<div class="p-6 bg-white dark:bg-(--black) min-h-screen font-sans"> <div class="flex flex-col mb-6">
<div class="flex justify-between items-center mb-6"> <h1 class="text-2xl font-bold text-black dark:text-white">{{ t('Addresses') }}</h1>
<h1 class="text-2xl font-bold text-black dark:text-white"> <div class="flex justify-between items-center">
{{ t('addresses.title') }} <div class="flex gap-2 items-center">
</h1> <UInput v-model="searchQuery" type="text" :placeholder="t('Search address')"
<UButton @click="showModal = true" class="bg-white dark:bg-gray-800 text-black dark:text-white absolute" />
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2"> <UIcon name="ic:baseline-search"
<span class="i-lucide-plus" /> class="text-[20px] text-(--accent-blue-light) dark:text-(--accent-blue-dark) relative left-40" />
{{ t('addresses.addNew') }} </div>
<UButton color="primary" @click="openCreateModal"
class="bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white hover:bg-(--accent-blue-dark) dark:hover:bg-(--accent-blue-light)">
<UIcon name="mdi:add-bold" />
{{ t('Add Address') }}
</UButton> </UButton>
</div>
</div>
</div> <div v-if="paginatedAddresses.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="mb-6">
<UInput v-model="searchQuery" type="text" :placeholder="t('addresses.searchPlaceholder')"
class="bg-white dark:bg-gray-800 text-black dark:text-white w-[30%]" />
</div>
<div v-if="paginatedAddresses.length > 0" class="space-y-4">
<div v-for="address in paginatedAddresses" :key="address.id" <div v-for="address in paginatedAddresses" :key="address.id"
class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800 hover:shadow-md transition-shadow"> class="border border-(--border-light) dark:border-(--border-dark) rounded-md p-4 bg-(--second-light) dark:bg-(--main-dark) hover:shadow-md transition-shadow">
<div class="flex justify-between items-start"> <p class="text-black dark:text-white">{{ address.street }}</p>
<div class="flex-1"> <p class="text-black dark:text-white">{{ address.zipCode }}, {{ address.city }}</p>
<p class="text-black dark:text-white mt-1">
{{ address.street }}
</p>
<p class="text-black dark:text-white flex gap-2">
{{ address.zipCode }}, <span>{{ address.city }}</span>
</p>
<p class="text-black dark:text-white">{{ address.country }}</p> <p class="text-black dark:text-white">{{ address.country }}</p>
</div> <div class="flex gap-2 mt-2">
</div> <UButton size="sm" color="neutral" variant="outline" @click="openEditModal(address)"
</div> class="text-(--accent-blue-light) dark:text-(--accent-blue-dark)">{{ t('edit') }}
</div>
<div v-else class="text-center py-12 text-gray-500 dark:text-gray-400">
<span class="i-lucide-map-pin text-4xl mb-4 block" />
<p class="text-lg">{{ t('addresses.noAddresses') }}</p>
<UButton @click="showModal"
class="mt-4 px-4 py-2 bg-primary text-white rounded-lg hover:bg-blue-600 transition-colors">
{{ t('addresses.addFirst') }}
</UButton> </UButton>
<button size="sm" color="destructive" variant="outline" @click="confirmDelete(address.id)"
class="text-red-500 hover:bg-red-100 dark:hover:bg-red-900 dark:hover:text-red-100 rounded transition-colors p-2">
<UIcon name="material-symbols:delete" class="text-[16px]" />
</button>
</div> </div>
<form @submit.prevent="saveAddress" class="space-y-4 mt-4"> </div>
</div>
<div v-else class="text-center py-8 text-gray-500 dark:text-gray-400">{{ t('No addresses found') }}</div>
<div class="mt-6 flex justify-center">
<UPagination v-model:page="page" :total="totalItems" :page-size="pageSize" />
</div>
<UModal v-model:open="showModal" :overlay="true" class="max-w-md mx-auto">
<template #content>
<div class="p-6 flex flex-col gap-6">
<p class="text-[20px] text-black dark:text-white ">Address</p>
<UForm @submit.prevent="saveAddress" class="space-y-4" :validate="validate">
<div> <div>
<label class="block text-sm font-medium text-black dark:text-white mb-1">Street *</label> <label class="block text-sm font-medium text-black dark:text-white mb-1">Street *</label>
<UInput v-model="formData.street" placeholder="Enter street" /> <UInput v-model="formData.street" placeholder="Enter street" name="street" class="w-full" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-black dark:text-white mb-1">Zip Code *</label> <label class="block text-sm font-medium text-black dark:text-white mb-1">Zip Code *</label>
<UInput v-model="formData.zipCode" placeholder="Enter zip code" /> <UInput v-model="formData.zipCode" placeholder="Enter zip code" name="zipCode"
class="w-full" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-black dark:text-white mb-1">City *</label> <label class="block text-sm font-medium text-black dark:text-white mb-1">City *</label>
<UInput v-model="formData.city" placeholder="Enter city" /> <UInput v-model="formData.city" placeholder="Enter city" name="city" class="w-full" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-black dark:text-white mb-1">Country *</label> <label class="block text-sm font-medium text-black dark:text-white mb-1">Country *</label>
<UInput v-model="formData.country" placeholder="Enter country" /> <UInput v-model="formData.country" placeholder="Enter country" name="country"
class="w-full" />
</div> </div>
</UForm>
<div class="flex justify-end gap-2">
<UButton variant="outline" color="neutral" @click="closeModal"
class="text-black dark:text-white">{{ t('Cancel') }}</UButton>
<UButton variant="outline" color="neutral" @click="saveAddress"
class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) hover:bg-(--accent-blue-dark) dark:hover:bg-(--accent-blue-light)">
{{ t('Save') }}</UButton>
</div>
</div>
</template>
</UModal>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700"> <UModal v-model:open="showDeleteConfirm" :overlay="true" class="max-w-md mx-auto">
<UButton color="neutral" variant="outline" @click="closeModal">Cancel</UButton> <template #content>
<UButton type="submit" color="primary">Save</UButton> <div class="p-6 flex flex-col gap-3">
<div class="flex flex-col gap-2 justify-center items-center">
<p class="flex items-end gap-2 dark:text-white text-black">
<UIcon name='f7:exclamationmark-triangle' class="text-[35px] text-red-700" />
Confirm Delete
</p>
<p class="text-gray-700 dark:text-gray-300">
{{ t('Are you sure you want to delete this address?') }}</p>
</div> </div>
</form> <div class="flex justify-center gap-5">
<UButton variant="outline" color="neutral" @click="showDeleteConfirm = false"
class="dark:text-white text-black">{{ t('Cancel') }}
</UButton>
<UButton variant="outline" color="neutral" @click="deleteAddress" class="text-red-700">
{{ t('Delete') }}</UButton>
</div> </div>
</div> </div>
</template>
</UModal>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router' import { useAddressStore } from '@/stores/address'
import { useAddressStore, type Address, type AddressFormData } from '@/stores/address'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const router = useRouter()
const addressStore = useAddressStore() const addressStore = useAddressStore()
const { t } = useI18n() const { t } = useI18n()
const searchQuery = ref('')
const showModal = ref(false) const showModal = ref(false)
const isEditing = ref(false) const isEditing = ref(false)
const editingAddressId = ref<number | null>(null) const editingAddressId = ref<number | null>(null)
const formData = ref({ street: '', zipCode: '', city: '', country: '' })
const formData = ref<AddressFormData>({ const showDeleteConfirm = ref(false)
street: '', const addressToDelete = ref<number | null>(null)
zipCode: '',
city: '',
country: ''
})
const formErrors = ref<Record<string, string>>({})
const searchQuery = ref('')
let searchTimeout: ReturnType<typeof setTimeout> | null = null
watch(searchQuery, (newValue) => {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
addressStore.setSearchQuery(newValue)
}, 300)
})
const page = ref(addressStore.currentPage)
const paginatedAddresses = computed(() => addressStore.paginatedAddresses) const paginatedAddresses = computed(() => addressStore.paginatedAddresses)
const totalItems = computed(() => addressStore.totalItems)
const pageSize = addressStore.pageSize
watch(page, (newPage) => addressStore.setPage(newPage))
watch(searchQuery, (val) => {
addressStore.setSearchQuery(val)
})
function openCreateModal() {
resetForm()
isEditing.value = false
showModal.value = true
}
function openEditModal(address: any) {
formData.value = {
street: address.street,
zipCode: address.zipCode,
city: address.city,
country: address.country
}
isEditing.value = true
editingAddressId.value = address.id
showModal.value = true
}
function resetForm() { function resetForm() {
formData.value = { formData.value = { street: '', zipCode: '', city: '', country: '' }
street: '', editingAddressId.value = null
zipCode: '',
city: '',
country: ''
}
formErrors.value = {}
} }
function closeModal() { function closeModal() {
@@ -120,35 +155,35 @@ function closeModal() {
resetForm() resetForm()
} }
function validateForm(): boolean { function validate() {
formErrors.value = {} const errors = []
if (!formData.value.street) errors.push({ name: 'street', message: 'Street required' })
if (!formData.value.street.trim()) { if (!formData.value.zipCode) errors.push({ name: 'zipCode', message: 'Zip Code required' })
formErrors.value.street = 'Street is required' if (!formData.value.city) errors.push({ name: 'city', message: 'City required' })
} if (!formData.value.country) errors.push({ name: 'country', message: 'Country required' })
if (!formData.value.zipCode.trim()) { return errors.length ? errors : null
formErrors.value.zipCode = 'Zip Code is required'
}
if (!formData.value.city.trim()) {
formErrors.value.city = 'City is required'
}
if (!formData.value.country.trim()) {
formErrors.value.country = 'Country is required'
}
return Object.keys(formErrors.value).length === 0
} }
function saveAddress() { function saveAddress() {
if (!validateForm()) return if (validate()) return
if (isEditing.value && editingAddressId.value) { if (isEditing.value && editingAddressId.value) {
(editingAddressId.value, formData.value) addressStore.updateAddress(editingAddressId.value, formData.value)
} else { } else {
addressStore.addAddress(formData.value) addressStore.addAddress(formData.value)
} }
closeModal() closeModal()
} }
function confirmDelete(id: number) {
addressToDelete.value = id
showDeleteConfirm.value = true
}
function deleteAddress() {
if (addressToDelete.value) {
addressStore.deleteAddress(addressToDelete.value)
}
showDeleteConfirm.value = false
addressToDelete.value = null
}
</script> </script>

View File

@@ -0,0 +1,218 @@
<template>
<div class="container mx-auto mt-20 px-4 py-8">
<h1 class="text-2xl font-bold text-black dark:text-white mb-8">{{ t('Shopping Cart') }}</h1>
<div class="flex flex-col lg:flex-row gap-8 mb-8">
<div class="flex-1">
<div class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) overflow-hidden">
<h2 class="text-lg font-semibold text-black dark:text-white p-4 border-b border-(--border-light) dark:border-(--border-dark)">
{{ t('Selected Products') }}
</h2>
<div class="hidden md:grid grid-cols-12 gap-4 p-4 bg-(--second-light) dark:bg-(--main-dark) text-sm font-medium text-gray-600 dark:text-gray-300 border-b border-(--border-light) dark:border-(--border-dark)">
<div class="col-span-4">{{ t('Product') }}</div>
<div class="col-span-2 text-right">{{ t('Price') }}</div>
<div class="col-span-3 text-center">{{ t('Quantity') }}</div>
<div class="col-span-2 text-right">{{ t('Total') }}</div>
<div class="col-span-1 text-center">{{ t('Actions') }}</div>
</div>
<div v-if="cartStore.items.length > 0">
<div v-for="item in cartStore.items" :key="item.id"
class="grid grid-cols-1 md:grid-cols-12 gap-4 p-4 border-b border-(--border-light) dark:border-(--border-dark) items-center">
<div class="col-span-4 flex items-center gap-4">
<div class="w-16 h-16 bg-(--second-light) dark:bg-(--main-dark) rounded flex items-center justify-center overflow-hidden">
<img v-if="item.image" :src="item.image" :alt="item.name" class="w-full h-full object-cover" />
<UIcon v-else name="mdi:package-variant" class="text-2xl text-gray-400" />
</div>
<span class="text-black dark:text-white text-sm font-medium">{{ item.name }}</span>
</div>
<div class="col-span-2 text-right">
<span class="md:hidden text-gray-500 dark:text-gray-400 text-sm">{{ t('Price') }}: </span>
<span class="text-black dark:text-white">${{ item.price.toFixed(2) }}</span>
</div>
<div class="col-span-3 flex items-center justify-center">
<div class="flex items-center border border-(--border-light) dark:border-(--border-dark) rounded">
<button @click="decreaseQuantity(item)" class="px-3 py-1 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<UIcon name="mdi:minus" />
</button>
<span class="px-3 py-1 text-black dark:text-white min-w-[40px] text-center">{{ item.quantity }}</span>
<button @click="increaseQuantity(item)" class="px-3 py-1 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<UIcon name="mdi:plus" />
</button>
</div>
</div>
<div class="col-span-2 text-right">
<span class="md:hidden text-gray-500 dark:text-gray-400 text-sm">{{ t('Total') }}: </span>
<span class="text-black dark:text-white font-medium">${{ (item.price * item.quantity).toFixed(2) }}</span>
</div>
<div class="col-span-1 flex justify-center">
<button @click="removeItem(item.id)" class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors" :title="t('Remove')">
<UIcon name="material-symbols:delete" class="text-[20px]" />
</button>
</div>
</div>
</div>
<div v-else class="p-8 text-center">
<UIcon name="mdi:cart-outline" class="text-6xl text-gray-300 dark:text-gray-600 mb-4" />
<p class="text-gray-500 dark:text-gray-400">{{ t('Your cart is empty') }}</p>
<RouterLink :to="{ name: 'product-card-full' }" class="inline-block mt-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark) hover:underline">
{{ t('Continue Shopping') }}
</RouterLink>
</div>
</div>
</div>
<div class="lg:w-80">
<div class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6 sticky top-24">
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Order Summary') }}</h2>
<div class="space-y-3 border-b border-(--border-light) dark:border-(--border-dark) pb-4 mb-4">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">{{ t('Products total') }}</span>
<span class="text-black dark:text-white">${{ cartStore.productsTotal.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">{{ t('Shipping') }}</span>
<span class="text-black dark:text-white">
{{ cartStore.shippingCost > 0 ? `$${cartStore.shippingCost.toFixed(2)}` : t('Free') }}
</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">{{ t('VAT') }} ({{ (cartStore.vatRate * 100).toFixed(0) }}%)</span>
<span class="text-black dark:text-white">${{ cartStore.vatAmount.toFixed(2) }}</span>
</div>
</div>
<div class="flex justify-between mb-6">
<span class="text-black dark:text-white font-semibold text-lg">{{ t('Total') }}</span>
<span class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) font-bold text-lg">${{ cartStore.orderTotal.toFixed(2) }}</span>
</div>
<div class="flex flex-col gap-3">
<UButton block color="primary" @click="placeOrder" :disabled="!canPlaceOrder"
class="bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white hover:bg-(--accent-blue-dark) dark:hover:bg-(--accent-blue-light) disabled:opacity-50 disabled:cursor-not-allowed">
{{ t('Place Order') }}
</UButton>
<UButton block variant="outline" color="neutral" @click="cancelOrder"
class="text-black dark:text-white border-(--border-light) dark:border-(--border-dark) hover:bg-gray-100 dark:hover:bg-gray-700">
{{ t('Cancel') }}
</UButton>
</div>
</div>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-8">
<div class="flex-1">
<div class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6">
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Select Delivery Address') }}</h2>
<div class="mb-4">
<UInput v-model="addressSearchQuery" type="text" :placeholder="t('Search address')"
class="w-full bg-white dark:bg-gray-800 text-black dark:text-white" />
</div>
<div v-if="addressStore.filteredAddresses.length > 0" class="space-y-3">
<label v-for="address in addressStore.filteredAddresses" :key="address.id"
class="flex items-start gap-3 p-4 border rounded-lg cursor-pointer transition-colors"
:class="cartStore.selectedAddressId === address.id
? 'border-(--accent-blue-light) dark:border-(--accent-blue-dark) bg-blue-50 dark:bg-blue-900/20'
: 'border-(--border-light) dark:border-(--border-dark) hover:border-gray-400'">
<input type="radio" :value="address.id" v-model="selectedAddress"
class="mt-1 w-4 h-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark)" />
<div class="flex-1">
<p class="text-black dark:text-white font-medium">{{ address.street }}</p>
<p class="text-gray-600 dark:text-gray-400 text-sm">{{ address.zipCode }}, {{ address.city }}</p>
<p class="text-gray-600 dark:text-gray-400 text-sm">{{ address.country }}</p>
</div>
</label>
</div>
<div v-else class="text-center py-6">
<UIcon name="mdi:map-marker-outline" class="text-4xl text-gray-400 mb-2" />
<p class="text-gray-500 dark:text-gray-400">{{ t('No addresses found') }}</p>
<RouterLink :to="{ name: 'addresses' }" class="inline-block mt-2 text-(--accent-blue-light) dark:text-(--accent-blue-dark) hover:underline">
{{ t('Add Address') }}
</RouterLink>
</div>
</div>
</div>
<div class="flex-1">
<div class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6">
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Delivery Method') }}</h2>
<div class="space-y-3">
<label v-for="method in cartStore.deliveryMethods" :key="method.id"
class="flex items-center gap-3 p-4 border rounded-lg cursor-pointer transition-colors"
:class="cartStore.selectedDeliveryMethodId === method.id
? 'border-(--accent-blue-light) dark:border-(--accent-blue-dark) bg-blue-50 dark:bg-blue-900/20'
: 'border-(--border-light) dark:border-(--border-dark) hover:border-gray-400'">
<input type="radio" :value="method.id" v-model="selectedDeliveryMethod"
class="w-4 h-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark)" />
<div class="flex-1">
<div class="flex justify-between items-center">
<span class="text-black dark:text-white font-medium">{{ method.name }}</span>
<span class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) font-medium">
{{ method.price > 0 ? `$${method.price.toFixed(2)}` : t('Free') }}
</span>
</div>
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ method.description }}</p>
</div>
</label>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useCartStore, type CartItem } from '@/stores/cart'
import { useAddressStore } from '@/stores/address'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const cartStore = useCartStore()
const addressStore = useAddressStore()
const { t } = useI18n()
const router = useRouter()
const selectedAddress = ref<number | null>(cartStore.selectedAddressId)
const selectedDeliveryMethod = ref<number | null>(cartStore.selectedDeliveryMethodId)
const addressSearchQuery = ref('')
watch(addressSearchQuery, (val) => {
addressStore.setSearchQuery(val)
})
watch(selectedAddress, (newValue) => {
cartStore.setSelectedAddress(newValue)
})
watch(selectedDeliveryMethod, (newValue) => {
if (newValue) {
cartStore.setDeliveryMethod(newValue)
}
})
const canPlaceOrder = computed(() => {
return cartStore.items.length > 0 &&
cartStore.selectedAddressId !== null &&
cartStore.selectedDeliveryMethodId !== null
})
function increaseQuantity(item: CartItem) {
cartStore.updateQuantity(item.id, item.quantity + 1)
}
function decreaseQuantity(item: CartItem) {
cartStore.updateQuantity(item.id, item.quantity - 1)
}
function removeItem(itemId: number) {
cartStore.removeItem(itemId)
}
function placeOrder() {
if (canPlaceOrder.value) {
console.log('Placing order...')
alert(t('Order placed successfully!'))
cartStore.clearCart()
router.push({ name: 'home' })
}
}
function cancelOrder() {
router.back()
}
</script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="container mt-14"> <div class="container mt-14 mx-auto">
<div class="flex justify-between gap-8 mb-6"> <div class="flex justify-between gap-8 mb-6">
<div class="flex-1"> <div class="flex-1">
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-8 flex items-center justify-center min-h-[300px]"> <div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-8 flex items-center justify-center min-h-[300px]">

View File

@@ -33,6 +33,7 @@ const router = createRouter({
{ path: 'products-datail/', component: () => import('../components/admin/ProductDetailView.vue'), name: 'product-detail' }, { path: 'products-datail/', component: () => import('../components/admin/ProductDetailView.vue'), name: 'product-detail' },
{ path: 'product-card-full/', component: () => import('../components/customer/PageProductCardFull.vue'), name: 'product-card-full' }, { path: 'product-card-full/', component: () => import('../components/customer/PageProductCardFull.vue'), name: 'product-card-full' },
{ path: 'addresses', component: () => import('../components/customer/PageAddresses.vue'), name: 'addresses' }, { path: 'addresses', component: () => import('../components/customer/PageAddresses.vue'), name: 'addresses' },
{ path: 'cart', component: () => import('../components/customer/PageCart.vue'), name: 'cart' },
], ],
}, },
{ {

View File

@@ -22,42 +22,20 @@ export const useAddressStore = defineStore('address', () => {
const error = ref<string | null>(null) const error = ref<string | null>(null)
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = 10 const pageSize = 20
const totalItems = computed(() => filteredAddresses.value.length)
const totalPages = computed(() => Math.ceil(totalItems.value / pageSize))
const searchQuery = ref('') const searchQuery = ref('')
function initMockData() { function initMockData() {
addresses.value = [ addresses.value = [
{ { id: 1, street: 'Main Street 123', zipCode: '10-001', city: 'New York', country: 'United States' },
id: 1, { id: 2, street: 'Oak Avenue 123', zipCode: '90-001', city: 'Los Angeles', country: 'United States' },
street: 'Main Street 123', { id: 3, street: 'Pine Road 123', zipCode: '60-601', city: 'Chicago', country: 'United States' }
zipCode: '10-001',
city: 'New York',
country: 'United States'
},
{
id: 2,
street: 'Oak Avenue 123',
zipCode: '90-001',
city: 'Los Angeles',
country: 'United States'
},
{
id: 3,
street: 'Pine Road 123 ',
zipCode: '60-601',
city: 'Chicago',
country: 'United States'
}
] ]
} }
const filteredAddresses = computed(() => { const filteredAddresses = computed(() => {
if (!searchQuery.value) { if (!searchQuery.value) return addresses.value
return addresses.value
}
const query = searchQuery.value.toLowerCase() const query = searchQuery.value.toLowerCase()
@@ -68,34 +46,64 @@ export const useAddressStore = defineStore('address', () => {
addr.zipCode.toLowerCase().includes(query) addr.zipCode.toLowerCase().includes(query)
) )
}) })
const totalItems = computed(() => filteredAddresses.value.length)
const totalPages = computed(() => Math.ceil(totalItems.value / pageSize))
const paginatedAddresses = computed(() => { const paginatedAddresses = computed(() => {
const start = (currentPage.value - 1) * pageSize const start = (currentPage.value - 1) * pageSize
const end = start + pageSize return filteredAddresses.value.slice(start, start + pageSize)
return filteredAddresses.value.slice(start, end)
}) })
function getAddressById(id: number): Address | undefined { function getAddressById(id: number) {
return addresses.value.find(addr => addr.id === id) return addresses.value.find(addr => addr.id === id)
} }
function addAddress(formData: AddressFormData): Address { function normalize(data: AddressFormData): AddressFormData {
const now = new Date().toISOString() return {
street: data.street.trim(),
const newAddress: Address = { zipCode: data.zipCode.trim(),
id: Date.now(), city: data.city.trim(),
...formData, country: data.country.trim()
}
} }
addresses.value.push(newAddress) function generateId(): number {
return Math.max(0, ...addresses.value.map(a => a.id)) + 1
}
function addAddress(formData: AddressFormData): Address {
const newAddress: Address = {
id: generateId(),
...normalize(formData)
}
addresses.value.unshift(newAddress)
resetPagination()
return newAddress return newAddress
} }
function updateAddress(id: number, formData: AddressFormData): boolean {
const index = addresses.value.findIndex(a => a.id === id)
if (index === -1) return false
const existing = addresses.value[index]
if (!existing) return false
addresses.value[index] = {
id: existing.id,
...normalize(formData)
}
return true
}
function deleteAddress(id: number): boolean { function deleteAddress(id: number): boolean {
const index = addresses.value.findIndex(addr => addr.id === id) const index = addresses.value.findIndex(a => a.id === id)
if (index === -1) return false if (index === -1) return false
addresses.value.splice(index, 1) addresses.value.splice(index, 1)
resetPagination()
return true return true
} }
@@ -128,6 +136,7 @@ export const useAddressStore = defineStore('address', () => {
paginatedAddresses, paginatedAddresses,
getAddressById, getAddressById,
addAddress, addAddress,
updateAddress,
deleteAddress, deleteAddress,
setPage, setPage,
setSearchQuery, setSearchQuery,

113
bo/src/stores/cart.ts Normal file
View File

@@ -0,0 +1,113 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface CartItem {
id: number
productId: number
name: string
image: string
price: number
quantity: number
}
export interface DeliveryMethod {
id: number
name: string
price: number
description: string
}
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const selectedAddressId = ref<number | null>(null)
const selectedDeliveryMethodId = ref<number | null>(null)
const shippingCost = ref(0)
const vatRate = ref(0.23) // 23% VAT
const deliveryMethods = ref<DeliveryMethod[]>([
{ id: 1, name: 'Standard Delivery', price: 0, description: '5-7 business days' },
{ id: 2, name: 'Express Delivery', price: 15, description: '2-3 business days' },
{ id: 3, name: 'Priority Delivery', price: 30, description: 'Next business day' }
])
function initMockData() {
items.value = [
{ id: 1, productId: 101, name: 'Premium Widget Pro', image: '/img/product-1.jpg', price: 129.99, quantity: 2 },
{ id: 2, productId: 102, name: 'Ultra Gadget X', image: '/img/product-2.jpg', price: 89.50, quantity: 1 },
{ id: 3, productId: 103, name: 'Mega Tool Set', image: '/img/product-3.jpg', price: 249.00, quantity: 3 }
]
}
const productsTotal = computed(() => {
return items.value.reduce((sum, item) => sum + (item.price * item.quantity), 0)
})
const vatAmount = computed(() => {
return productsTotal.value * vatRate.value
})
const orderTotal = computed(() => {
return productsTotal.value + shippingCost.value + vatAmount.value
})
const itemCount = computed(() => {
return items.value.reduce((sum, item) => sum + item.quantity, 0)
})
function updateQuantity(itemId: number, quantity: number) {
const item = items.value.find(i => i.id === itemId)
if (item) {
if (quantity <= 0) {
removeItem(itemId)
} else {
item.quantity = quantity
}
}
}
function removeItem(itemId: number) {
const index = items.value.findIndex(i => i.id === itemId)
if (index !== -1) {
items.value.splice(index, 1)
}
}
function clearCart() {
items.value = []
selectedAddressId.value = null
selectedDeliveryMethodId.value = null
shippingCost.value = 0
}
function setSelectedAddress(addressId: number | null) {
selectedAddressId.value = addressId
}
function setDeliveryMethod(methodId: number) {
selectedDeliveryMethodId.value = methodId
const method = deliveryMethods.value.find(m => m.id === methodId)
if (method) {
shippingCost.value = method.price
}
}
initMockData()
return {
items,
selectedAddressId,
selectedDeliveryMethodId,
shippingCost,
vatRate,
deliveryMethods,
productsTotal,
vatAmount,
orderTotal,
itemCount,
updateQuantity,
removeItem,
clearCart,
setSelectedAddress,
setDeliveryMethod
}
})

View File

@@ -15,8 +15,6 @@ import { useAuthStore } from '@/stores/auth'
import { i18n } from '@/plugins/02_i18n' import { i18n } from '@/plugins/02_i18n'
import type { TableColumn } from '@nuxt/ui' import type { TableColumn } from '@nuxt/ui'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import ProductDetailView from './customer/ProductDetailView.vue'
import ProductsView from './customer/ProductsView.vue'
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale) ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)

1
go.mod
View File

@@ -12,6 +12,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/openai/openai-go/v3 v3.28.0 github.com/openai/openai-go/v3 v3.28.0
github.com/samber/lo v1.53.0
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
google.golang.org/api v0.247.0 google.golang.org/api v0.247.0

2
go.sum
View File

@@ -124,6 +124,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=

View File

@@ -24,7 +24,8 @@ INSERT IGNORE INTO b2b_language
VALUES VALUES
(1, '2022-09-16 17:10:02.837', '2026-03-02 21:24:36.779730', NULL, 'Polski', 'pl', 'pl', '__-__-____', '__-__', 0, 0, 1, '🇵🇱'), (1, '2022-09-16 17:10:02.837', '2026-03-02 21:24:36.779730', NULL, 'Polski', 'pl', 'pl', '__-__-____', '__-__', 0, 0, 1, '🇵🇱'),
(2, '2022-09-16 17:10:02.852', '2026-03-02 21:24:36.779730', NULL, 'English', 'en', 'en', '__-__-____', '__-__', 0, 1, 1, '🇬🇧'), (2, '2022-09-16 17:10:02.852', '2026-03-02 21:24:36.779730', NULL, 'English', 'en', 'en', '__-__-____', '__-__', 0, 1, 1, '🇬🇧'),
(3, '2022-09-16 17:10:02.865', '2026-03-02 21:24:36.779730', NULL, 'Čeština', 'cs', 'cs', '__-__-____', '__-__', 0, 0, 1, '🇨🇿'); (3, '2022-09-16 17:10:02.865', '2026-03-02 21:24:36.779730', NULL, 'Čeština', 'cs', 'cs', '__-__-____', '__-__', 0, 0, 0, '🇨🇿'),
(4, '2022-09-16 17:10:02.852', '2026-03-02 21:24:36.779730', NULL, 'Deutsch', 'de', 'de', '__-__-____', '__-__', 0, 0, 1, '🇩🇪');
CREATE TABLE IF NOT EXISTS b2b_components ( CREATE TABLE IF NOT EXISTS b2b_components (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -71,7 +72,8 @@ CREATE TABLE IF NOT EXISTS b2b_customers (
password_reset_expires DATETIME(6) NULL, password_reset_expires DATETIME(6) NULL,
last_password_reset_request DATETIME(6) NULL, last_password_reset_request DATETIME(6) NULL,
last_login_at DATETIME(6) NULL, last_login_at DATETIME(6) NULL,
lang VARCHAR(10) NULL DEFAULT 'en', lang_id BIGINT NULL DEFAULT 2,
country_id BIGINT NULL DEFAULT 2,
created_at DATETIME(6) NULL, created_at DATETIME(6) NULL,
updated_at DATETIME(6) NULL, updated_at DATETIME(6) NULL,
deleted_at DATETIME(6) NULL deleted_at DATETIME(6) NULL
@@ -113,13 +115,31 @@ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_customer_id ON b2b_refresh_tokens
-- insert sample admin user admin@ma-al.com/Maal12345678 -- insert sample admin user admin@ma-al.com/Maal12345678
INSERT IGNORE INTO b2b_customers (id, email, password, first_name, last_name, role, provider, provider_id, avatar_url, is_active, email_verified, email_verification_token, email_verification_expires, password_reset_token, password_reset_expires, last_password_reset_request, last_login_at, lang, created_at, updated_at, deleted_at) INSERT IGNORE INTO b2b_customers (id, email, password, first_name, last_name, role, provider, provider_id, avatar_url, is_active, email_verified, email_verification_token, email_verification_expires, password_reset_token, password_reset_expires, last_password_reset_request, last_login_at, lang_id, country_id, created_at, updated_at, deleted_at)
VALUES VALUES
(1, 'admin@ma-al.com', '$2a$10$Owy9DjrS0l3Fz4XoOvh5pulgmOMqdwXmb7hYE9BovnSuWS2plGr82', 'Super', 'Admin', 'admin', 'local', '', '', 1, 1, NULL, NULL, '', NULL, NULL, NULL, 'pl', '2026-03-02 16:55:10.252740', '2026-03-02 16:55:10.252740', NULL); (1, 'admin@ma-al.com', '$2a$10$Owy9DjrS0l3Fz4XoOvh5pulgmOMqdwXmb7hYE9BovnSuWS2plGr82', 'Super', 'Admin', 'admin', 'local', '', '', 1, 1, NULL, NULL, '', NULL, NULL, NULL, 1, 1, '2026-03-02 16:55:10.252740', '2026-03-02 16:55:10.252740', NULL);
ALTER TABLE b2b_customers AUTO_INCREMENT = 1; ALTER TABLE b2b_customers AUTO_INCREMENT = 1;
-- countries
CREATE TABLE IF NOT EXISTS b2b_countries (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
currency INT UNSIGNED NOT NULL,
flag VARCHAR(16) NOT NULL,
CONSTRAINT fk_countries_currency FOREIGN KEY (currency) REFERENCES ps_currency(id_currency) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO b2b_countries
(id, name, currency, flag)
VALUES
(1, 'Polska', 1, '🇵🇱'),
(2, 'England', 2, '🇬🇧'),
(3, 'Čeština', 2, '🇨🇿'),
(4, 'Deutschland', 2, '🇩🇪');
-- +goose Down -- +goose Down
DROP TABLE IF EXISTS b2b_countries;
DROP TABLE IF EXISTS b2b_language; DROP TABLE IF EXISTS b2b_language;
DROP TABLE IF EXISTS b2b_components; DROP TABLE IF EXISTS b2b_components;
DROP TABLE IF EXISTS b2b_scopes; DROP TABLE IF EXISTS b2b_scopes;

View File

@@ -0,0 +1,224 @@
-- +goose Up
DELIMITER //
DROP PROCEDURE IF EXISTS get_full_product //
CREATE PROCEDURE get_full_product(
IN p_id_product INT UNSIGNED,
IN p_id_shop INT UNSIGNED,
IN p_id_lang INT UNSIGNED,
IN p_id_customer INT UNSIGNED,
IN p_id_group INT UNSIGNED,
IN p_id_currency INT UNSIGNED,
IN p_id_country INT UNSIGNED,
IN p_quantity INT UNSIGNED
)
BEGIN
DECLARE v_tax_rate DECIMAL(10,4) DEFAULT 0;
SELECT COALESCE(t.rate, 0.0000) INTO v_tax_rate
FROM ps_tax_rule tr
INNER JOIN ps_tax t
ON t.id_tax = tr.id_tax
WHERE tr.id_tax_rules_group = (
SELECT ps.id_tax_rules_group
FROM ps_product_shop ps
WHERE ps.id_product = p_id_product
AND ps.id_shop = p_id_shop
LIMIT 1
)
AND tr.id_country = p_id_country
ORDER BY
tr.id_state DESC,
tr.zipcode_from != '' DESC,
tr.id_tax_rule DESC
LIMIT 1;
/* FINAL JSON */
SELECT JSON_OBJECT(
/* ================= PRODUCT ================= */
'id_product', p.id_product,
'reference', p.reference,
'name', pl.name,
'description', pl.description,
'short_description', pl.description_short,
/* ================= PRICE ================= */
'price', JSON_OBJECT(
'base', COALESCE(ps.price, p.price),
'final_tax_excl',
(
COALESCE(ps.price, p.price)
- IFNULL(
CASE
WHEN sp.reduction_type = 'amount' THEN sp.reduction
WHEN sp.reduction_type = 'percentage' THEN COALESCE(ps.price, p.price) * sp.reduction
ELSE 0
END, 0
)
),
'final_tax_incl',
(
(
COALESCE(ps.price, p.price)
- IFNULL(
CASE
WHEN sp.reduction_type = 'amount' THEN sp.reduction
WHEN sp.reduction_type = 'percentage' THEN COALESCE(ps.price, p.price) * sp.reduction
ELSE 0
END, 0
)
) * (1 + v_tax_rate / 100)
)
),
/* ================= META ================= */
'active', COALESCE(ps.active, p.active),
'visibility', COALESCE(ps.visibility, p.visibility),
'manufacturer', m.name,
'category', cl.name,
/* ================= IMAGE ================= */
'cover_image', JSON_OBJECT(
'id', i.id_image,
'legend', il.legend
),
/* ================= FEATURES ================= */
'features', (
SELECT JSON_ARRAYAGG(
JSON_OBJECT(
'name', fl.name,
'value', fvl.value
)
)
FROM ps_feature_product fp
JOIN ps_feature_lang fl
ON fl.id_feature = fp.id_feature AND fl.id_lang = p_id_lang
JOIN ps_feature_value_lang fvl
ON fvl.id_feature_value = fp.id_feature_value AND fvl.id_lang = p_id_lang
WHERE fp.id_product = p.id_product
),
/* ================= COMBINATIONS ================= */
'combinations', (
SELECT JSON_ARRAYAGG(
JSON_OBJECT(
'id_product_attribute', pa.id_product_attribute,
'reference', pa.reference,
'price', JSON_OBJECT(
'impact', COALESCE(pas.price, pa.price),
'final_tax_excl',
(
COALESCE(ps.price, p.price)
+ COALESCE(pas.price, pa.price)
),
'final_tax_incl',
(
(
COALESCE(ps.price, p.price)
+ COALESCE(pas.price, pa.price)
) * (1 + v_tax_rate / 100)
)
),
'stock', IFNULL(sa.quantity, 0),
'default_on', pas.default_on,
/* ATTRIBUTES JSON */
'attributes', (
SELECT JSON_ARRAYAGG(
JSON_OBJECT(
'group', agl.name,
'attribute', al.name
)
)
FROM ps_product_attribute_combination pac
JOIN ps_attribute a ON a.id_attribute = pac.id_attribute
JOIN ps_attribute_lang al
ON al.id_attribute = a.id_attribute AND al.id_lang = p_id_lang
JOIN ps_attribute_group_lang agl
ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = p_id_lang
WHERE pac.id_product_attribute = pa.id_product_attribute
),
/* IMAGES */
'images', (
SELECT JSON_ARRAYAGG(img.id_image)
FROM ps_product_attribute_image pai
JOIN ps_image img ON img.id_image = pai.id_image
WHERE pai.id_product_attribute = pa.id_product_attribute
)
)
)
FROM ps_product_attribute pa
JOIN ps_product_attribute_shop pas
ON pas.id_product_attribute = pa.id_product_attribute
AND pas.id_shop = p_id_shop
LEFT JOIN ps_stock_available sa
ON sa.id_product = pa.id_product
AND sa.id_product_attribute = pa.id_product_attribute
AND sa.id_shop = p_id_shop
WHERE pa.id_product = p.id_product
)
) AS product_json
FROM ps_product p
LEFT JOIN ps_product_shop ps
ON ps.id_product = p.id_product AND ps.id_shop = p_id_shop
LEFT JOIN ps_product_lang pl
ON pl.id_product = p.id_product
AND pl.id_lang = p_id_lang
AND pl.id_shop = p_id_shop
LEFT JOIN ps_category_lang cl
ON cl.id_category = COALESCE(ps.id_category_default, p.id_category_default)
AND cl.id_lang = p_id_lang
AND cl.id_shop = p_id_shop
LEFT JOIN ps_manufacturer m
ON m.id_manufacturer = p.id_manufacturer
LEFT JOIN ps_image i
ON i.id_product = p.id_product AND i.cover = 1
LEFT JOIN ps_image_lang il
ON il.id_image = i.id_image AND il.id_lang = p_id_lang
/* SPECIFIC PRICE */
LEFT JOIN (
SELECT sp1.*
FROM ps_specific_price sp1
WHERE sp1.id_product = p_id_product
AND (sp1.id_customer = 0 OR sp1.id_customer = p_id_customer)
AND (sp1.id_group = 0 OR sp1.id_group = p_id_group)
AND (sp1.id_currency = 0 OR sp1.id_currency = p_id_currency)
AND sp1.from_quantity <= p_quantity
ORDER BY
sp1.id_customer DESC,
sp1.id_group DESC,
sp1.from_quantity DESC,
sp1.id_specific_price DESC
LIMIT 1
) sp ON sp.id_product = p.id_product
WHERE p.id_product = p_id_product
LIMIT 1;
END //
DELIMITER ;
-- +goose Down

View File

@@ -0,0 +1,36 @@
package langsAndCountriesRepo
import (
"git.ma-al.com/goc_daniel/b2b/app/db"
"git.ma-al.com/goc_daniel/b2b/app/model"
)
type UILangsAndCountriesRepo interface {
GetLanguages() ([]model.Language, error)
GetCountriesAndCurrencies() ([]model.Country, error)
}
type LangsAndCountriesRepo struct{}
func New() UILangsAndCountriesRepo {
return &LangsAndCountriesRepo{}
}
func (repo *LangsAndCountriesRepo) GetLanguages() ([]model.Language, error) {
var languages []model.Language
err := db.DB.Table("b2b_language").Scan(&languages).Error
return languages, err
}
func (repo *LangsAndCountriesRepo) GetCountriesAndCurrencies() ([]model.Country, error) {
var countries []model.Country
err := db.DB.Table("b2b_countries").
Select("b2b_countries.id, b2b_countries.name, b2b_countries.flag, ps_currency.id as id_currency, ps_currency.name as currency_name, ps_currency.iso_code as currency_iso_code").
Joins("JOIN ps_currency ON ps_currency.id = b2b_countries.currency").
Scan(&countries).Error
return countries, err
}

View File

@@ -0,0 +1,58 @@
package listProductsRepo
import (
"git.ma-al.com/goc_daniel/b2b/app/db"
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/filters"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/find"
)
type UIListProductsRepo interface {
GetListing(p find.Paging, filt *filters.FiltersList) (find.Found[model.Product], error)
}
type ListProductsRepo struct{}
func New() UIListProductsRepo {
return &ListProductsRepo{}
}
func (repo *ListProductsRepo) GetListing(p find.Paging, filt *filters.FiltersList) (find.Found[model.Product], error) {
var listing []model.Product
var total int64
// Apply filters here
q := db.DB.Table("ps_product")
// var resultIDs []uint
// q := db.DB.
// // SQL_CALC_FOUND_ROWS is a neat trick which works on MariaDB and
// // MySQL. It works when followed by `SELECT FOUND_ROWS();`. To learn
// // more see: https://mariarawmodel.com/kb/en/found_rows/
// // WARN: This might not work on different SQL databases
// Select("DISTINCT SQL_CALC_FOUND_ROWS id").
// // Debug().
// Scopes(view.FromDBViewForDisplay(langID, countryIso)).
// Scopes(scopesForFiltersOnDisplay(db.DB, langID, countryIso, filt)).
// Scopes(filt.OfCategory(filters.ORDER_FILTER)...).
// Limit(p.Limit()).
// Offset(p.Offset())
err := q.Count(&total).Error
if err != nil {
return find.Found[model.Product]{}, err
}
err = q.
Limit(p.Limit()).
Offset(p.Offset()).
Scan(&listing).Error
if err != nil {
return find.Found[model.Product]{}, err
}
return find.Found[model.Product]{
Items: listing,
Count: uint(total),
}, nil
}

View File

@@ -0,0 +1,74 @@
package productDescriptionRepo
import (
"fmt"
"git.ma-al.com/goc_daniel/b2b/app/db"
"git.ma-al.com/goc_daniel/b2b/app/model"
)
type UIProductDescriptionRepo interface {
GetProductDescription(productID uint, productShopID uint, productLangID uint) (*model.ProductDescription, error)
CreateIfDoesNotExist(productID uint, productShopID uint, productLangID uint) error
UpdateFields(productID uint, productShopID uint, productLangID uint, updates map[string]string) error
}
type ProductDescriptionRepo struct{}
func New() UIProductDescriptionRepo {
return &ProductDescriptionRepo{}
}
// We assume that any user has access to all product descriptions
func (r *ProductDescriptionRepo) GetProductDescription(productID uint, productShopID uint, productLangID uint) (*model.ProductDescription, error) {
var ProductDescription model.ProductDescription
err := db.DB.
Table("ps_product_lang").
Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID).
First(&ProductDescription).Error
if err != nil {
return nil, fmt.Errorf("database error: %w", err)
}
return &ProductDescription, nil
}
// If it doesn't exist, returns an error.
func (r *ProductDescriptionRepo) CreateIfDoesNotExist(productID uint, productShopID uint, productLangID uint) error {
record := model.ProductDescription{
ProductID: productID,
ShopID: productShopID,
LangID: productLangID,
}
err := db.DB.
Table("ps_product_lang").
Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID).
FirstOrCreate(&record).Error
if err != nil {
return fmt.Errorf("database error: %w", err)
}
return nil
}
func (r *ProductDescriptionRepo) UpdateFields(productID uint, productShopID uint, productLangID uint, updates map[string]string) error {
if len(updates) == 0 {
return nil
}
updatesIface := make(map[string]interface{}, len(updates))
for k, v := range updates {
updatesIface[k] = v
}
err := db.DB.
Table("ps_product_lang").
Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID).
Updates(updatesIface).Error
if err != nil {
return fmt.Errorf("database error: %w", err)
}
return nil
}