15 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

9
bo/components.d.ts vendored
View File

@@ -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']

View File

@@ -2,10 +2,6 @@ import type { NuxtUIOptions } from '@nuxt/ui/unplugin'
export const uiOptions: NuxtUIOptions = { export const uiOptions: NuxtUIOptions = {
ui: { ui: {
colors: {
primary: 'blue',
neutral: 'zink',
},
pagination: { pagination: {
slots: { slots: {
root: '', root: '',
@@ -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)',
}
} }
} }
} }

View File

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

View File

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

View File

@@ -1,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>

View 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>

View File

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

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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
View 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
View File

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

View File

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

1
go.mod
View File

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

2
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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