Compare commits
15 Commits
e30088209e
...
product-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| dd806bbb1e | |||
|
|
b6bf6ed5c6 | ||
| 7a66d6f429 | |||
| 43f856ee8d | |||
| 506c64e240 | |||
| 718b4d23f1 | |||
| 22e8556c9d | |||
|
|
52c17d7017 | ||
|
|
e094865fc7 | ||
|
|
01c8f4333f | ||
|
|
6cebcacb5d | ||
| c79e08dbb8 | |||
| 789d59b0c9 | |||
| 7388d0f828 | |||
|
|
a0dcb56fda |
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
52
app/delivery/web/api/restricted/langsAndCountries.go
Normal file
52
app/delivery/web/api/restricted/langsAndCountries.go
Normal 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)))
|
||||||
|
}
|
||||||
120
app/delivery/web/api/restricted/listProducts.go
Normal file
120
app/delivery/web/api/restricted/listProducts.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
11
app/model/countries.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -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
77
app/model/product.go
Normal 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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
59
app/service/listProductsService/listProductsService.go
Normal file
59
app/service/listProductsService/listProductsService.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
150
app/utils/query/filters/filters.go
Normal file
150
app/utils/query/filters/filters.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/utils/query/filters/filters_list.go
Normal file
107
app/utils/query/filters/filters_list.go
Normal 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
|
||||||
|
}
|
||||||
159
app/utils/query/find/find.go
Normal file
159
app/utils/query/find/find.go
Normal 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++
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/utils/query/find/found_rows_callback.go
Normal file
46
app/utils/query/find/found_rows_callback.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/utils/query/find/sql_calc_rows.go
Normal file
51
app/utils/query/find/sql_calc_rows.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/utils/query/query_params/key_mapping.go
Normal file
43
app/utils/query/query_params/key_mapping.go
Normal 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]())
|
||||||
|
}
|
||||||
63
app/utils/query/query_params/params_query.go
Normal file
63
app/utils/query/query_params/params_query.go
Normal 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)
|
||||||
|
}
|
||||||
82
app/utils/query/query_params/parse_sort.go
Normal file
82
app/utils/query/query_params/parse_sort.go
Normal 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
|
||||||
|
// }
|
||||||
|
// }
|
||||||
75
app/utils/query/query_params/where_scope_from_query.go
Normal file
75
app/utils/query/query_params/where_scope_from_query.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/utils/query/queryparser/queryparser.go
Normal file
37
app/utils/query/queryparser/queryparser.go
Normal 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
|
||||||
|
}
|
||||||
90
app/utils/reflect/reflect.go
Normal file
90
app/utils/reflect/reflect.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
9
bo/components.d.ts
vendored
9
bo/components.d.ts
vendored
@@ -16,8 +16,15 @@ declare module 'vue' {
|
|||||||
En_PrivacyPolicyView: typeof import('./src/components/terms/en_PrivacyPolicyView.vue')['default']
|
En_PrivacyPolicyView: typeof import('./src/components/terms/en_PrivacyPolicyView.vue')['default']
|
||||||
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']
|
||||||
|
PageCart: typeof import('./src/components/customer/PageCart.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']
|
||||||
|
ProductCustomization: typeof import('./src/components/customer/components/ProductCustomization.vue')['default']
|
||||||
|
ProductDetailView: typeof import('./src/components/admin/ProductDetailView.vue')['default']
|
||||||
|
ProductsView: typeof import('./src/components/admin/ProductsView.vue')['default']
|
||||||
|
ProductVariants: typeof import('./src/components/customer/components/ProductVariants.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']
|
||||||
@@ -32,6 +39,8 @@ declare module 'vue' {
|
|||||||
UFormField: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
|
UFormField: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
|
||||||
UIcon: typeof import('./node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
|
UIcon: typeof import('./node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
|
||||||
UInput: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
|
UInput: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
|
||||||
|
UInputNumber: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/InputNumber.vue')['default']
|
||||||
|
UModal: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
|
||||||
UPagination: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Pagination.vue')['default']
|
UPagination: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Pagination.vue')['default']
|
||||||
USelect: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Select.vue')['default']
|
USelect: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Select.vue')['default']
|
||||||
USelectMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
|
USelectMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
|
||||||
|
|||||||
@@ -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: '',
|
||||||
@@ -22,6 +18,13 @@ export const uiOptions: NuxtUIOptions = {
|
|||||||
error: 'text-red-600!'
|
error: 'text-red-600!'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
inputNumber: {
|
||||||
|
slots: {
|
||||||
|
base: 'text-(--black) dark:text-white border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! pt-2 px-1! w-auto!',
|
||||||
|
increment: 'border-0! pe-0! ps-0!',
|
||||||
|
decrement: 'border-0! pe-0! ps-0!'
|
||||||
|
},
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
slots: {
|
slots: {
|
||||||
base: 'w-full! cursor-pointer border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0!',
|
base: 'w-full! cursor-pointer border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0!',
|
||||||
@@ -49,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)',
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
@@ -22,6 +22,15 @@ const authStore = useAuthStore()
|
|||||||
<RouterLink :to="{ name: 'product-detail' }">
|
<RouterLink :to="{ name: 'product-detail' }">
|
||||||
product detail
|
product detail
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'product-card-full' }">
|
||||||
|
ProductCardFull
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'addresses' }">
|
||||||
|
Addresses
|
||||||
|
</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 />
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container my-10 ">
|
<div class="container my-10 mx-auto ">
|
||||||
|
|
||||||
<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">
|
<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">
|
||||||
<div class="flex items-end gap-3">
|
<div class="flex items-end gap-3">
|
||||||
<USelect v-model="selectedLanguage" :items="availableLangs" variant="outline" class="w-40!"
|
<USelect v-model="selectedLanguage" :items="availableLangs" variant="outline" class="w-40!" valueKey="iso_code">
|
||||||
valueKey="iso_code">
|
|
||||||
<template #default="{ modelValue }">
|
<template #default="{ modelValue }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-md">{{availableLangs.find(x => x.iso_code == modelValue)?.flag}}</span>
|
<span class="text-md">{{availableLangs.find(x => x.iso_code == modelValue)?.flag}}</span>
|
||||||
<span class="font-medium dark:text-white text-black">{{ availableLangs.find(x => x.iso_code == modelValue)?.name }}</span>
|
<span class="font-medium dark:text-white text-black">{{availableLangs.find(x => x.iso_code ==
|
||||||
|
modelValue)?.name}}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #item-leading="{ item }">
|
<template #item-leading="{ item }">
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</USelect>
|
</USelect>
|
||||||
</div>
|
</div>
|
||||||
<UButton @click="translateToSelectedLanguage" color="primary" :loading="translating" class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) px-12!">
|
<UButton @click="translateToSelectedLanguage" color="primary" :loading="translating"
|
||||||
|
class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) px-12!">
|
||||||
Translate
|
Translate
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,23 +35,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start gap-30">
|
<div class="flex items-start gap-30">
|
||||||
<div class="flex flex-col gap-10">
|
<p class="p-80 bg-(--second-light)">img</p>
|
||||||
<p class="p-60 bg-yellow-300">img</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<p class="text-[25px] font-bold dark:text-white text-black">{{ productStore.productDescription.name }}</p>
|
<p class="text-[25px] font-bold text-black dark:text-white">
|
||||||
<p v-html="productStore.productDescription.description_short" class="dark:text-white text-black"></p>
|
{{ productStore.productDescription.name }}
|
||||||
|
</p>
|
||||||
|
<p v-html="productStore.productDescription.description_short" class="text-black dark:text-white"></p>
|
||||||
<div class="space-[10px]">
|
<div class="space-y-[10px]">
|
||||||
<div class="flex gap-1 items-center">
|
<div class="flex items-center gap-1">
|
||||||
<UIcon name="lets-icons:done-ring-round-fill" class="text-[20px] text-green-600" />
|
<UIcon name="lets-icons:done-ring-round-fill" class="text-[20px] text-green-600" />
|
||||||
<p class=" gap-1text-[16px] font-bold text-(--accent-blue-light) dark:text-(--accent-blue-dark)">{{
|
<p class="text-[16px] font-bold text-(--accent-blue-light) dark:text-(--accent-blue-dark)">
|
||||||
productStore.productDescription.available_now }}</p>
|
{{ productStore.productDescription.available_now }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1 items-center">
|
<div class="flex items-center gap-1">
|
||||||
<UIcon name="marketeq:car-shipping" class="text-[25px] text-green-600" />
|
<UIcon name="marketeq:car-shipping" class="text-[25px] text-green-600" />
|
||||||
<p class="text-[18px] font-bold dark:text-white text-black">{{ productStore.productDescription.delivery_in_stock }}</p>
|
<p class="text-[18px] font-bold text-black dark:text-white">
|
||||||
|
{{ productStore.productDescription.delivery_in_stock }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,30 +73,39 @@
|
|||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeTab === 'usage'" class="px-8 py-4 border border-(--border-light) dark:border-(--border-dark) rounded-md bg-(--second-light) dark:bg-(--main-dark)">
|
<div v-if="activeTab === 'usage'"
|
||||||
|
class="px-8 py-4 border border-(--border-light) dark:border-(--border-dark) rounded-md bg-(--second-light) dark:bg-(--main-dark)">
|
||||||
|
|
||||||
<div class="flex justify-end items-center gap-3 mb-4">
|
<div class="flex justify-end items-center gap-3 mb-4">
|
||||||
<UButton @click="usageEdit.enableEdit()" class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
|
<UButton v-if="!isEditing" @click="enableEdit"
|
||||||
|
class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
|
||||||
<p class="text-white">Change Text</p>
|
<p class="text-white">Change Text</p>
|
||||||
<UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-white!" />
|
<UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-white!" />
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton @click="save" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
|
<UButton v-if="isEditing" @click="saveText" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
|
||||||
<p class="dark:text-white text-black">Save the edited text</p>
|
<p class="dark:text-white text-black">Save the edited text</p>
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton v-if="isEditing" @click="cancelEdit" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
|
||||||
|
Cancel
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
<p ref="usageRef" v-html="productStore.productDescription.usage"
|
<p ref="usageRef" v-html="productStore.productDescription.usage"
|
||||||
class="flex flex-col justify-center w-full text-start dark:text-white text-black">
|
class="flex flex-col justify-center w-full text-start dark:text-white! text-black!"></p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeTab === 'description'" class="px-8 py-4 border border-(--border-light) dark:border-(--border-dark) rounded-md bg-(--second-light) dark:bg-(--main-dark)">
|
<div v-if="activeTab === 'description'"
|
||||||
|
class="px-8 py-4 border border-(--border-light) dark:border-(--border-dark) rounded-md bg-(--second-light) dark:bg-(--main-dark)">
|
||||||
<div class="flex items-center justify-end gap-3 mb-4">
|
<div class="flex items-center justify-end gap-3 mb-4">
|
||||||
<UButton @click="descriptionEdit.enableEdit()" class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
|
<UButton v-if="!descriptionEdit.isEditing.value" @click="enableDescriptionEdit"
|
||||||
|
class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
|
||||||
<p class="text-white">Change Text</p>
|
<p class="text-white">Change Text</p>
|
||||||
<UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-white!" />
|
<UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-white!" />
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton @click="save" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
|
<UButton v-if="descriptionEdit.isEditing.value" @click="saveDescription" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
|
||||||
<p class="dark:text-white text-black ">Save the edited text</p>
|
<p class="dark:text-white text-black ">Save the edited text</p>
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton v-if="descriptionEdit.isEditing.value" @click="cancelDescriptionEdit" color="neutral" variant="outline" class="p-2.5 cursor-pointer">Cancel</UButton>
|
||||||
</div>
|
</div>
|
||||||
<div ref="descriptionRef" v-html="productStore.productDescription.description"
|
<div ref="descriptionRef" v-html="productStore.productDescription.description"
|
||||||
class="flex flex-col justify-center dark:text-white text-black">
|
class="flex flex-col justify-center dark:text-white text-black">
|
||||||
@@ -118,6 +130,8 @@ const translating = ref(false)
|
|||||||
// return langs.filter((l: Language) => ['cs', 'pl', 'der'].includes(l.iso_code))
|
// return langs.filter((l: Language) => ['cs', 'pl', 'der'].includes(l.iso_code))
|
||||||
// })
|
// })
|
||||||
|
|
||||||
|
const isEditing = ref(false)
|
||||||
|
|
||||||
const availableLangs = computed(() => langs)
|
const availableLangs = computed(() => langs)
|
||||||
|
|
||||||
const selectedLanguage = ref('pl')
|
const selectedLanguage = ref('pl')
|
||||||
@@ -152,12 +166,50 @@ const usageRef = ref<HTMLElement | null>(null)
|
|||||||
const descriptionEdit = useEditable(descriptionRef)
|
const descriptionEdit = useEditable(descriptionRef)
|
||||||
const usageEdit = useEditable(usageRef)
|
const usageEdit = useEditable(usageRef)
|
||||||
|
|
||||||
const save = async () => {
|
const originalDescription = ref('')
|
||||||
|
const originalUsage = ref('')
|
||||||
|
|
||||||
|
const saveDescription = async () => {
|
||||||
descriptionEdit.disableEdit()
|
descriptionEdit.disableEdit()
|
||||||
usageEdit.disableEdit()
|
|
||||||
await productStore.saveProductDescription()
|
await productStore.saveProductDescription()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cancelDescriptionEdit = () => {
|
||||||
|
if (descriptionRef.value) {
|
||||||
|
descriptionRef.value.innerHTML = originalDescription.value
|
||||||
|
}
|
||||||
|
descriptionEdit.disableEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableDescriptionEdit = () => {
|
||||||
|
if (descriptionRef.value) {
|
||||||
|
originalDescription.value = descriptionRef.value.innerHTML
|
||||||
|
}
|
||||||
|
descriptionEdit.enableEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableEdit = () => {
|
||||||
|
if (usageRef.value) {
|
||||||
|
originalUsage.value = usageRef.value.innerHTML
|
||||||
|
}
|
||||||
|
isEditing.value = true
|
||||||
|
usageEdit.enableEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveText = () => {
|
||||||
|
usageEdit.disableEdit()
|
||||||
|
isEditing.value = false
|
||||||
|
productStore.saveProductDescription()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
if (usageRef.value) {
|
||||||
|
usageRef.value.innerHTML = originalUsage.value
|
||||||
|
}
|
||||||
|
usageEdit.disableEdit()
|
||||||
|
isEditing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
189
bo/src/components/customer/PageAddresses.vue
Normal file
189
bo/src/components/customer/PageAddresses.vue
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mx-auto mt-10">
|
||||||
|
<div class="flex flex-col mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-black dark:text-white">{{ t('Addresses') }}</h1>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<UInput v-model="searchQuery" type="text" :placeholder="t('Search address')"
|
||||||
|
class="bg-white dark:bg-gray-800 text-black dark:text-white absolute" />
|
||||||
|
<UIcon name="ic:baseline-search"
|
||||||
|
class="text-[20px] text-(--accent-blue-light) dark:text-(--accent-blue-dark) relative left-40" />
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="paginatedAddresses.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div v-for="address in paginatedAddresses" :key="address.id"
|
||||||
|
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">
|
||||||
|
<p class="text-black dark:text-white">{{ address.street }}</p>
|
||||||
|
<p class="text-black dark:text-white">{{ address.zipCode }}, {{ address.city }}</p>
|
||||||
|
<p class="text-black dark:text-white">{{ address.country }}</p>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<UButton size="sm" color="neutral" variant="outline" @click="openEditModal(address)"
|
||||||
|
class="text-(--accent-blue-light) dark:text-(--accent-blue-dark)">{{ t('edit') }}
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
<label class="block text-sm font-medium text-black dark:text-white mb-1">Street *</label>
|
||||||
|
<UInput v-model="formData.street" placeholder="Enter street" name="street" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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" name="zipCode"
|
||||||
|
class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-black dark:text-white mb-1">City *</label>
|
||||||
|
<UInput v-model="formData.city" placeholder="Enter city" name="city" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-black dark:text-white mb-1">Country *</label>
|
||||||
|
<UInput v-model="formData.country" placeholder="Enter country" name="country"
|
||||||
|
class="w-full" />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<UModal v-model:open="showDeleteConfirm" :overlay="true" class="max-w-md mx-auto">
|
||||||
|
<template #content>
|
||||||
|
<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 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>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useAddressStore } from '@/stores/address'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const addressStore = useAddressStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const showModal = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const editingAddressId = ref<number | null>(null)
|
||||||
|
const formData = ref({ street: '', zipCode: '', city: '', country: '' })
|
||||||
|
|
||||||
|
const showDeleteConfirm = ref(false)
|
||||||
|
const addressToDelete = ref<number | null>(null)
|
||||||
|
|
||||||
|
const page = ref(addressStore.currentPage)
|
||||||
|
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() {
|
||||||
|
formData.value = { street: '', zipCode: '', city: '', country: '' }
|
||||||
|
editingAddressId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
const errors = []
|
||||||
|
if (!formData.value.street) errors.push({ name: 'street', message: 'Street required' })
|
||||||
|
if (!formData.value.zipCode) errors.push({ name: 'zipCode', message: 'Zip Code required' })
|
||||||
|
if (!formData.value.city) errors.push({ name: 'city', message: 'City required' })
|
||||||
|
if (!formData.value.country) errors.push({ name: 'country', message: 'Country required' })
|
||||||
|
return errors.length ? errors : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAddress() {
|
||||||
|
if (validate()) return
|
||||||
|
if (isEditing.value && editingAddressId.value) {
|
||||||
|
addressStore.updateAddress(editingAddressId.value, formData.value)
|
||||||
|
} else {
|
||||||
|
addressStore.addAddress(formData.value)
|
||||||
|
}
|
||||||
|
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>
|
||||||
218
bo/src/components/customer/PageCart.vue
Normal file
218
bo/src/components/customer/PageCart.vue
Normal 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>
|
||||||
164
bo/src/components/customer/PageProductCardFull.vue
Normal file
164
bo/src/components/customer/PageProductCardFull.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mt-14 mx-auto">
|
||||||
|
<div class="flex justify-between gap-8 mb-6">
|
||||||
|
<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]">
|
||||||
|
<img :src="selectedColor?.image || productData.image" :alt="productData.name"
|
||||||
|
class="max-w-full h-auto object-contain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex flex-col gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{{ productData.name }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300">
|
||||||
|
{{ productData.description }}
|
||||||
|
</p>
|
||||||
|
<div class="text-3xl font-bold text-(--accent-blue-light) dark:text-(--accent-blue-dark)">
|
||||||
|
{{ productData.price }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Dimensions:</span>
|
||||||
|
<p class="font-medium dark:text-white">{{ productData.dimensions }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Seat Height:</span>
|
||||||
|
<p class="font-medium dark:text-white">{{ productData.seatHeight }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-end mb-8">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<span class="text-sm text-(--accent-blue-light) dark:text-(--accent-blue-dark) ">Colors:</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button v-for="color in productData.colors" :key="color.id" @click="selectedColor = color"
|
||||||
|
class="w-10 h-10 border-2 transition-all" :class="selectedColor?.id === color.id
|
||||||
|
? 'border-(--accent-blue-light) ring-2 ring-blue-600 ring-offset-2'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'"
|
||||||
|
:style="{ backgroundColor: color.hex }" :title="color.name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-5 items-end">
|
||||||
|
<UInputNumber v-model="value" />
|
||||||
|
<UButton color="primary" class="px-14! bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white">
|
||||||
|
Add to Cart
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProductCustomization />
|
||||||
|
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
|
||||||
|
<div class="mb-6 w-[55%]">
|
||||||
|
<div class="flex justify-between items-center gap-10 mb-8">
|
||||||
|
<UButton v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
|
||||||
|
'px-15 py-2 cursor-pointer',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-blue-600 hover:text-black hover:dark:text-white text-white'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
]" variant="ghost">
|
||||||
|
{{ tab.label }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
<div class="py-5 px-3 bg-(--second-light) dark:bg-(--main-dark) rounded-md">
|
||||||
|
<p class="dark:text-white whitespace-pre-line">
|
||||||
|
{{ activeTabContent }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
|
||||||
|
<ProductVariants />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import ProductCustomization from './components/ProductCustomization.vue'
|
||||||
|
import ProductVariants from './components/ProductVariants.vue'
|
||||||
|
|
||||||
|
interface Color {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
hex: string
|
||||||
|
image: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductData {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
price: string
|
||||||
|
dimensions: string
|
||||||
|
seatHeight: string
|
||||||
|
image: string
|
||||||
|
colors: Color[]
|
||||||
|
descriptionText: string
|
||||||
|
howToUseText: string
|
||||||
|
productDetailsText: string
|
||||||
|
documentsText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTab = ref('description')
|
||||||
|
const value = ref(5)
|
||||||
|
const selectedColor = ref<Color | null>(null)
|
||||||
|
|
||||||
|
const productData: ProductData = {
|
||||||
|
name: 'Larger Corner Sofa',
|
||||||
|
description: 'The large corner sofa is a comfortable seating solution for young children. It is upholstered with phthalate-free PVC-coated material intended for medical products, making it very easy to clean and disinfect. The product is available in a wide range of colours (13 colours), allowing it to fit into any interior.',
|
||||||
|
price: 'PLN 519.00 (VAT 23%)',
|
||||||
|
dimensions: '65 x 65 x 120 cm',
|
||||||
|
seatHeight: '45-55 cm',
|
||||||
|
image: '/placeholder-chair.jpg',
|
||||||
|
colors: [
|
||||||
|
{ id: 'black', name: 'Black', hex: '#1a1a1a', image: '/chair-black.jpg' },
|
||||||
|
{ id: 'gray', name: 'Gray', hex: '#6b7280', image: '/chair-gray.jpg' },
|
||||||
|
{ id: 'blue', name: 'Blue', hex: '#3b82f6', image: '/chair-blue.jpg' },
|
||||||
|
{ id: 'brown', name: 'Brown', hex: '#92400e', image: '/chair-brown.jpg' },
|
||||||
|
],
|
||||||
|
descriptionText: 'The large corner sofa is a comfortable seating solution for young children. It is upholstered with phthalate-free PVC-coated material intended for medical products, making it very easy to clean and disinfect. The product is available in a wide range of colours (13 colours), allowing it to fit into any interior',
|
||||||
|
howToUseText: '1. Adjust the seat height using the lever under the seat.\n2. Set the lumbar support to your preferred position.\n3. Adjust the armrests for optimal arm support.\n4. Use the recline tension knob to adjust the backrest resistance.\n5. Lock the recline position when needed.',
|
||||||
|
productDetailsText: '• Material: Mesh, Foam, Plastic\n• Max Load: 150 kg\n• Weight: 18 kg\n• Warranty: 2 years\n• Certifications: BIFMA, EN 1335',
|
||||||
|
documentsText: '• Assembly Instructions (PDF)\n• User Manual (PDF)\n• Warranty Terms (PDF)\n• Safety Certificate (PDF)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'description', label: 'Description' },
|
||||||
|
{ id: 'howToUse', label: 'How to Use' },
|
||||||
|
{ id: 'productDetails', label: 'Product Details' },
|
||||||
|
{ id: 'documents', label: 'Documents' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const userActions = [
|
||||||
|
'View detailed product information',
|
||||||
|
'Browse product images and available colors',
|
||||||
|
'Check product dimensions and specifications',
|
||||||
|
'Select a product variant',
|
||||||
|
'Select quantity',
|
||||||
|
'Add the product to the cart',
|
||||||
|
'Navigate between product description, usage instructions, and product details',
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeTabContent = computed(() => {
|
||||||
|
switch (activeTab.value) {
|
||||||
|
case 'description':
|
||||||
|
return productData.descriptionText
|
||||||
|
case 'howToUse':
|
||||||
|
return productData.howToUseText
|
||||||
|
case 'productDetails':
|
||||||
|
return productData.productDetailsText
|
||||||
|
case 'documents':
|
||||||
|
return productData.documentsText
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (productData.colors.length > 0) {
|
||||||
|
selectedColor.value = productData.colors[0] as Color
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.product-card-full {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container flex flex-col gap-8">
|
||||||
|
<div class="space-y-1 dark:text-white text-black">
|
||||||
|
<p class="text-[24px] font-bold">Product customization</p>
|
||||||
|
<p class="text-[15px]">Don't forget to save your customization to be able to add to cart</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-10">
|
||||||
|
<UInput label="Podaj kolor kanapy narożnej" placeholder="Podaj kolor kanapy narożnej" class="dark:text-white text-black"/>
|
||||||
|
<UInput label="Podaj kolor fotela" placeholder="Podaj kolor fotela" class="dark:text-white text-black"/>
|
||||||
|
<UInput label="Podaj kolor kwadratu" placeholder="Podaj kolor kwadratu" class="dark:text-white text-black"/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end items-end mb-8">
|
||||||
|
<UButton class="px-10! bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white">Save</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
57
bo/src/components/customer/components/ProductVariants.vue
Normal file
57
bo/src/components/customer/components/ProductVariants.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container flex flex-col gap-8">
|
||||||
|
<p class="text-[24px] font-bold dark:text-white text-black">Product Variants:</p>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div v-for="(variant, index) in variants" :key="index" class="flex gap-10">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-15 border border-(--border-light) dark:border-(--border-dark) p-5 rounded-md hover:bg-gray-50 hover:dark:bg-gray-700 bg-(--second-light) dark:bg-(--second-dark) dark:text-white text-black">
|
||||||
|
<img :src="variant.image" :alt="variant.image" class="w-16 h-16 object-cover" />
|
||||||
|
<p class="">{{ variant.name }}</p>
|
||||||
|
<p class="">{{ variant.productNumber }}</p>
|
||||||
|
<p class="">{{ variant.value }}</p>
|
||||||
|
<p class="">{{ variant.price }}</p>
|
||||||
|
<p class="">{{ variant.quantity }}</p>
|
||||||
|
<button class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
|
||||||
|
@click="addToCart(variant)">
|
||||||
|
Add to Cart
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const variants = ref([
|
||||||
|
{
|
||||||
|
image: 'img',
|
||||||
|
name: 'Duży fotelik narożny ',
|
||||||
|
productNumber: 'NC209/7000',
|
||||||
|
value: '20,000',
|
||||||
|
price: '519,00 zł',
|
||||||
|
quantity: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: 'img',
|
||||||
|
name: 'Duży fotelik narożny ',
|
||||||
|
productNumber: 'NC209/7000',
|
||||||
|
value: '20,000',
|
||||||
|
price: '519,00 zł',
|
||||||
|
quantity: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: 'img',
|
||||||
|
name: 'Duży fotelik narożny ',
|
||||||
|
productNumber: 'NC209/7000',
|
||||||
|
value: '20,000',
|
||||||
|
price: '519,00 zł',
|
||||||
|
quantity: 8
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const addToCart = (variant: any) => {
|
||||||
|
console.log('Added to cart:', variant)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -5,8 +5,6 @@ import { getSettings } from './settings'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import Default from '@/layouts/default.vue'
|
import Default from '@/layouts/default.vue'
|
||||||
|
|
||||||
// Helper: read the non-HTTPOnly is_authenticated cookie set by the backend.
|
|
||||||
// The backend sets it to "1" on login and removes it on logout.
|
|
||||||
function isAuthenticated(): boolean {
|
function isAuthenticated(): boolean {
|
||||||
if (typeof document === 'undefined') return false
|
if (typeof document === 'undefined') return false
|
||||||
return document.cookie.split('; ').some((c) => c === 'is_authenticated=1')
|
return document.cookie.split('; ').some((c) => c === 'is_authenticated=1')
|
||||||
@@ -31,8 +29,11 @@ const router = createRouter({
|
|||||||
component: Default,
|
component: Default,
|
||||||
children: [
|
children: [
|
||||||
{ path: '', component: () => import('../views/RepoChartView.vue'), name: 'home' },
|
{ path: '', component: () => import('../views/RepoChartView.vue'), name: 'home' },
|
||||||
{ path: 'products', component: () => import('../views/customer/ProductsView.vue'), name: 'products' },
|
{ path: 'products', component: () => import('../components/admin/ProductsView.vue'), name: 'products' },
|
||||||
{ path: 'products-datail/', component: () => import('../views/customer/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: 'addresses', component: () => import('../components/customer/PageAddresses.vue'), name: 'addresses' },
|
||||||
|
{ path: 'cart', component: () => import('../components/customer/PageCart.vue'), name: 'cart' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -51,12 +52,10 @@ const router = createRouter({
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Navigation guard: language handling + auth protection
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
const locale = to.params.locale as string
|
const locale = to.params.locale as string
|
||||||
const localeLang = langs.find((x) => x.iso_code == locale)
|
const localeLang = langs.find((x) => x.iso_code == locale)
|
||||||
|
|
||||||
// Check if the locale is valid
|
|
||||||
if (locale && langs.length > 0) {
|
if (locale && langs.length > 0) {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
console.log(authStore.isAuthenticated, to, from)
|
console.log(authStore.isAuthenticated, to, from)
|
||||||
@@ -65,20 +64,16 @@ router.beforeEach((to, from, next) => {
|
|||||||
|
|
||||||
if (validLocale) {
|
if (validLocale) {
|
||||||
currentLang.value = localeLang
|
currentLang.value = localeLang
|
||||||
|
|
||||||
// Auth guard: if the route does NOT have meta.guest = true, require authentication
|
|
||||||
if (!to.meta?.guest && !isAuthenticated()) {
|
if (!to.meta?.guest && !isAuthenticated()) {
|
||||||
return next({ name: 'login', params: { locale } })
|
return next({ name: 'login', params: { locale } })
|
||||||
}
|
}
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
} else if (locale) {
|
} else if (locale) {
|
||||||
// Invalid locale - redirect to default language
|
|
||||||
return next(`/${currentLang.value?.iso_code}${to.path.replace(`/${locale}`, '') || '/'}`)
|
return next(`/${currentLang.value?.iso_code}${to.path.replace(`/${locale}`, '') || '/'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No locale in URL - redirect to default language
|
|
||||||
if (!locale && to.path !== '/') {
|
if (!locale && to.path !== '/') {
|
||||||
return next(`/${currentLang.value?.iso_code}${to.path}`)
|
return next(`/${currentLang.value?.iso_code}${to.path}`)
|
||||||
}
|
}
|
||||||
|
|||||||
145
bo/src/stores/address.ts
Normal file
145
bo/src/stores/address.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export interface AddressFormData {
|
||||||
|
street: string
|
||||||
|
zipCode: string
|
||||||
|
city: string
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Address {
|
||||||
|
id: number
|
||||||
|
street: string
|
||||||
|
zipCode: string
|
||||||
|
city: string
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAddressStore = defineStore('address', () => {
|
||||||
|
const addresses = ref<Address[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
function initMockData() {
|
||||||
|
addresses.value = [
|
||||||
|
{ id: 1, street: 'Main Street 123', 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(() => {
|
||||||
|
if (!searchQuery.value) return addresses.value
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
|
||||||
|
return addresses.value.filter(addr =>
|
||||||
|
addr.street.toLowerCase().includes(query) ||
|
||||||
|
addr.city.toLowerCase().includes(query) ||
|
||||||
|
addr.country.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 start = (currentPage.value - 1) * pageSize
|
||||||
|
return filteredAddresses.value.slice(start, start + pageSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getAddressById(id: number) {
|
||||||
|
return addresses.value.find(addr => addr.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(data: AddressFormData): AddressFormData {
|
||||||
|
return {
|
||||||
|
street: data.street.trim(),
|
||||||
|
zipCode: data.zipCode.trim(),
|
||||||
|
city: data.city.trim(),
|
||||||
|
country: data.country.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const index = addresses.value.findIndex(a => a.id === id)
|
||||||
|
if (index === -1) return false
|
||||||
|
|
||||||
|
addresses.value.splice(index, 1)
|
||||||
|
resetPagination()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPage(page: number) {
|
||||||
|
currentPage.value = page
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSearchQuery(query: string) {
|
||||||
|
searchQuery.value = query
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPagination() {
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
initMockData()
|
||||||
|
|
||||||
|
return {
|
||||||
|
addresses,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
totalItems,
|
||||||
|
totalPages,
|
||||||
|
searchQuery,
|
||||||
|
filteredAddresses,
|
||||||
|
paginatedAddresses,
|
||||||
|
getAddressById,
|
||||||
|
addAddress,
|
||||||
|
updateAddress,
|
||||||
|
deleteAddress,
|
||||||
|
setPage,
|
||||||
|
setSearchQuery,
|
||||||
|
resetPagination
|
||||||
|
}
|
||||||
|
})
|
||||||
113
bo/src/stores/cart.ts
Normal file
113
bo/src/stores/cart.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
1
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
224
i18n/migrations/20260319163200_procedures.sql
Normal file
224
i18n/migrations/20260319163200_procedures.sql
Normal 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
|
||||||
36
repository/langsAndCountriesRepo/langsAndCountriesRepo.go
Normal file
36
repository/langsAndCountriesRepo/langsAndCountriesRepo.go
Normal 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
|
||||||
|
}
|
||||||
58
repository/listProductsRepo/listProductsRepo.go
Normal file
58
repository/listProductsRepo/listProductsRepo.go
Normal 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
|
||||||
|
}
|
||||||
74
repository/productDescriptionRepo/productDescriptionRepo.go
Normal file
74
repository/productDescriptionRepo/productDescriptionRepo.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user