30 Commits

Author SHA1 Message Date
059808665f Merge remote-tracking branch 'origin/translate' into front-styles 2026-04-14 08:58:32 +02:00
b54645830f fix: store customer-product 2026-04-14 08:55:53 +02:00
1973189525 Merge remote-tracking branch 'origin/main' into front-styles 2026-04-14 08:18:19 +02:00
26cbdeec0a Merge pull request 'product-procedures' (#59) from product-procedures into main
Reviewed-on: #59
Reviewed-by: goc_daniel <goc_daniel@ma-al.com>
2026-04-13 13:32:28 +00:00
38cb07f3d4 chore: address pull request review issues 2026-04-13 14:22:14 +02:00
2e61cde742 Merge branch 'main' of ssh://git.ma-al.com:8822/goc_daniel/b2b into product-procedures 2026-04-10 15:26:36 +02:00
54608410ea feat: create specific price system and adapt product queries 2026-04-10 15:04:34 +02:00
f1a2f4c0b2 fix: fix storage file 2026-04-10 11:38:36 +02:00
bfd20aaa7b Merge branch 'main' of ssh://git.ma-al.com:8822/goc_daniel/b2b into translate 2026-04-10 10:44:47 +02:00
1fb2a33cfd fix: create component StorageFileBrowser 2026-04-09 16:00:36 +02:00
75af44b0df feat: product_attribute list with prices 2026-04-09 09:51:06 +02:00
0d2bf3a27f Merge branch 'main' of ssh://git.ma-al.com:8822/goc_daniel/b2b into translate 2026-04-09 08:28:47 +02:00
c7692bc817 fix: page serchUsers 2026-04-08 13:51:10 +02:00
8824796846 Merge branch 'main' of ssh://git.ma-al.com:8822/goc_daniel/b2b into translate 2026-04-08 10:09:40 +02:00
9eb8fc6625 fix: page usersList 2026-04-07 16:06:36 +02:00
a290a72d1d fix: style 2026-04-07 13:54:08 +02:00
b0d0338d23 Merge branch 'main' of ssh://git.ma-al.com:8822/goc_daniel/b2b into translate 2026-04-07 13:40:51 +02:00
849b18c4db Merge branch 'main' of ssh://git.ma-al.com:8822/goc_daniel/b2b into translate 2026-04-07 12:55:50 +02:00
a874a063d8 fix: page usersList 2026-04-07 10:06:36 +02:00
11dab263fa Merge branch 'main' of ssh://git.ma-al.com:8822/goc_daniel/b2b into translate 2026-04-07 08:03:23 +02:00
5bda48b94a Merge branch 'front-styles' of ssh://git.ma-al.com:8822/goc_daniel/b2b into translate 2026-04-07 08:02:32 +02:00
2f4ccbaf92 fix: import 2026-04-07 08:01:21 +02:00
4c505c0520 Merge branch 'front-styles' of ssh://git.ma-al.com:8822/goc_daniel/b2b into translate 2026-04-03 15:54:23 +02:00
2f7e313c95 fix: user info 2026-04-03 15:53:31 +02:00
110946a924 Merge remote-tracking branch 'origin/main' into front-styles 2026-04-03 14:35:29 +02:00
3083330fcd Merge branch 'front-styles' of ssh://git.ma-al.com:8822/goc_daniel/b2b into translate 2026-04-03 11:32:10 +02:00
b7c4b6e3fd fix: store 2026-04-03 11:32:04 +02:00
68f31952da Merge branch 'front-styles' of ssh://git.ma-al.com:8822/goc_daniel/b2b into translate 2026-04-03 10:56:21 +02:00
c1efcf1b86 fix: style 2026-04-03 10:55:12 +02:00
5cab7573a8 Merge remote-tracking branch 'origin/product-procedures' into front-styles 2026-04-03 10:54:35 +02:00
88 changed files with 2967 additions and 733 deletions

View File

@@ -2,27 +2,27 @@ package middleware
import (
"git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms"
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor"
"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"
)
func Require(p perms.Permission) fiber.Handler {
return func(c fiber.Ctx) error {
u := c.Locals("user")
if u == nil {
return c.SendStatus(fiber.StatusUnauthorized)
}
user, ok := u.(*model.UserSession)
user, ok := localeExtractor.GetCustomer(c)
if !ok {
return c.SendStatus(fiber.StatusInternalServerError)
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
}
for _, perm := range user.Permissions {
if perm == p {
for _, perm := range user.Role.Permissions {
if perm.Name == p {
return c.Next()
}
}
return c.SendStatus(fiber.StatusForbidden)
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrForbidden)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrForbidden)))
}
}

View File

@@ -3,8 +3,9 @@ package perms
type Permission string
const (
UserReadAny Permission = "user.read.any"
UserWriteAny Permission = "user.write.any"
UserDeleteAny Permission = "user.delete.any"
CurrencyWrite Permission = "currency.write"
UserReadAny Permission = "user.read.any"
UserWriteAny Permission = "user.write.any"
UserDeleteAny Permission = "user.delete.any"
CurrencyWrite Permission = "currency.write"
SpecificPriceManage Permission = "specific_price.manage"
)

View File

@@ -4,8 +4,9 @@ import (
"strconv"
"git.ma-al.com/goc_daniel/b2b/app/config"
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/model/dbmodel"
"git.ma-al.com/goc_daniel/b2b/app/service/productService"
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/localeExtractor"
"git.ma-al.com/goc_daniel/b2b/app/utils/nullable"
@@ -34,6 +35,7 @@ func ProductsHandlerRoutes(r fiber.Router) fiber.Router {
r.Get("/:id/:country_id/:quantity", handler.GetProductJson)
r.Get("/list", handler.ListProducts)
r.Get("/list-variants/:product_id", handler.ListProductVariants)
r.Post("/favorite/:product_id", handler.AddToFavorites)
r.Delete("/favorite/:product_id", handler.RemoveFromFavorites)
@@ -70,7 +72,7 @@ func (h *ProductsHandler) GetProductJson(c fiber.Ctx) error {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
productJson, err := h.productService.GetJSON(p_id_product, int(customer.LangID), int(customer.ID), b2b_id_country, p_quantity)
productJson, err := h.productService.Get(uint(p_id_product), customer.LangID, customer.ID, uint(b2b_id_country), uint(p_quantity))
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
@@ -80,25 +82,19 @@ func (h *ProductsHandler) GetProductJson(c fiber.Ctx) error {
}
func (h *ProductsHandler) ListProducts(c fiber.Ctx) error {
paging, filters, err := query_params.ParseFilters[model.Product](c, columnMappingListProducts)
paging, filters, err := query_params.ParseFilters[dbmodel.PsProduct](c, columnMappingListProducts)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
id_lang, ok := localeExtractor.GetLangID(c)
if !ok {
customer, ok := localeExtractor.GetCustomer(c)
if !ok || customer == nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
userID, ok := localeExtractor.GetUserID(c)
if !ok {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
}
list, err := h.productService.Find(id_lang, userID, paging, filters)
list, err := h.productService.Find(customer.LangID, customer.ID, paging, filters, customer, constdata.DEFAULT_PRODUCT_QUANTITY, constdata.SHOP_ID)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
@@ -164,3 +160,27 @@ func (h *ProductsHandler) RemoveFromFavorites(c fiber.Ctx) error {
return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK)))
}
func (h *ProductsHandler) ListProductVariants(c fiber.Ctx) error {
productIDStr := c.Params("product_id")
productID, err := strconv.Atoi(productIDStr)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
customer, ok := localeExtractor.GetCustomer(c)
if !ok || customer == nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
list, err := h.productService.GetProductAttributes(customer.LangID, uint(productID), constdata.SHOP_ID, customer.ID, customer.CountryID, constdata.DEFAULT_PRODUCT_QUANTITY)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&list, len(list), i18n.T_(c, response.Message_OK)))
}

View File

@@ -0,0 +1,159 @@
package restricted
import (
"strconv"
"git.ma-al.com/goc_daniel/b2b/app/config"
"git.ma-al.com/goc_daniel/b2b/app/delivery/middleware"
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/service/specificPriceService"
"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"
)
type SpecificPriceHandler struct {
SpecificPriceService *specificPriceService.SpecificPriceService
config *config.Config
}
func NewSpecificPriceHandler() *SpecificPriceHandler {
SpecificPriceService := specificPriceService.New()
return &SpecificPriceHandler{
SpecificPriceService: SpecificPriceService,
config: config.Get(),
}
}
func SpecificPriceHandlerRoutes(r fiber.Router) fiber.Router {
handler := NewSpecificPriceHandler()
r.Post("/", middleware.Require("specific_price.manage"), handler.Create)
r.Put("/:id", middleware.Require("specific_price.manage"), handler.Update)
r.Delete("/:id", middleware.Require("specific_price.manage"), handler.Delete)
r.Get("/", middleware.Require("specific_price.manage"), handler.List)
r.Get("/:id", middleware.Require("specific_price.manage"), handler.GetByID)
r.Patch("/:id/activate", middleware.Require("specific_price.manage"), handler.Activate)
r.Patch("/:id/deactivate", middleware.Require("specific_price.manage"), handler.Deactivate)
return r
}
func (h *SpecificPriceHandler) Create(c fiber.Ctx) error {
var pr model.SpecificPrice
if err := c.Bind().Body(&pr); err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
}
result, err := h.SpecificPriceService.Create(c.Context(), &pr)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&result, 1, i18n.T_(c, response.Message_OK)))
}
func (h *SpecificPriceHandler) Update(c fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
var pr model.SpecificPrice
if err := c.Bind().Body(&pr); err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
}
result, err := h.SpecificPriceService.Update(c.Context(), id, &pr)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&result, 1, i18n.T_(c, response.Message_OK)))
}
func (h *SpecificPriceHandler) List(c fiber.Ctx) error {
result, err := h.SpecificPriceService.List(c.Context())
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&result, 1, i18n.T_(c, response.Message_OK)))
}
func (h *SpecificPriceHandler) GetByID(c fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
result, err := h.SpecificPriceService.GetByID(c.Context(), id)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&result, 1, i18n.T_(c, response.Message_OK)))
}
func (h *SpecificPriceHandler) Activate(c fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
err = h.SpecificPriceService.SetActive(c.Context(), id, true)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK)))
}
func (h *SpecificPriceHandler) Deactivate(c fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
err = h.SpecificPriceService.SetActive(c.Context(), id, false)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK)))
}
func (h *SpecificPriceHandler) Delete(c fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
err = h.SpecificPriceService.Delete(c.Context(), id)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK)))
}

View File

@@ -132,6 +132,8 @@ func (s *Server) Setup() error {
carts := s.restricted.Group("/carts")
restricted.CartsHandlerRoutes(carts)
specificPrice := s.restricted.Group("/specific-price")
restricted.SpecificPriceHandlerRoutes(specificPrice)
// addresses (restricted)
addresses := s.restricted.Group("/addresses")
restricted.AddressesHandlerRoutes(addresses)

View File

@@ -1,15 +1,13 @@
package model
import "git.ma-al.com/goc_daniel/b2b/app/model/dbmodel"
// 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"`
PSCurrencyID uint `gorm:"column:currency_id" json:"currency_id"`
PSCurrency *dbmodel.PsCurrency `gorm:"foreignKey:PSCurrencyID;references:IDCurrency" json:"ps_currency"`
CurrencyID uint `gorm:"column:b2b_id_currency" json:"currency_id"`
Currency *Currency `gorm:"foreignKey:CurrencyID" json:"currency,omitempty"`
}
func (Country) TableName() string {

View File

@@ -31,6 +31,7 @@ type Customer struct {
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
LangID uint `gorm:"default:2" json:"lang_id"` // User's preferred language
CountryID uint `gorm:"default:2" json:"country_id"` // User's selected country
Country *Country `gorm:"foreignKey:CountryID" json:"country,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`

View File

@@ -1,76 +1,17 @@
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 ProductInList struct {
ProductID uint `gorm:"column:product_id" json:"product_id" form:"product_id"`
Name string `gorm:"column:name" json:"name" form:"name"`
LinkRewrite string `gorm:"column:link_rewrite" json:"link_rewrite"`
ImageLink string `gorm:"column:image_link" json:"image_link"`
CategoryName string `gorm:"column:category_name" json:"category_name" form:"category_name"`
Reference string `gorm:"column:reference" json:"reference"`
VariantsNumber uint `gorm:"column:variants_number" json:"variants_number"`
Quantity int64 `gorm:"column:quantity" json:"quantity"`
IsFavorite bool `gorm:"column:is_favorite" json:"is_favorite"`
ProductID uint `gorm:"column:product_id" json:"product_id" form:"product_id"`
Name string `gorm:"column:name" json:"name" form:"name"`
LinkRewrite string `gorm:"column:link_rewrite" json:"link_rewrite"`
ImageLink string `gorm:"column:image_link" json:"image_link"`
CategoryName string `gorm:"column:category_name" json:"category_name" form:"category_name"`
Reference string `gorm:"column:reference" json:"reference"`
VariantsNumber uint `gorm:"column:variants_number" json:"variants_number"`
Quantity int64 `gorm:"column:quantity" json:"quantity"`
PriceTaxExcl float64 `gorm:"column:price_tax_excl" json:"price_tax_excl"`
PriceTaxIncl float64 `gorm:"column:price_tax_incl" json:"price_tax_incl"`
IsFavorite bool `gorm:"column:is_favorite" json:"is_favorite"`
}
type ProductFilters struct {

View File

@@ -0,0 +1,29 @@
package model
import "time"
type SpecificPrice struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"type:varchar(255);not null" json:"name"`
ValidFrom *time.Time `gorm:"null" json:"valid_from"`
ValidTill *time.Time `gorm:"null" json:"valid_till"`
HasExpirationDate bool `gorm:"default:false" json:"has_expiration_date"`
ReductionType string `gorm:"type:enum('amount','percentage');not null" json:"reduction_type"`
Price *float64 `gorm:"type:decimal(10,2);null" json:"price"`
CurrencyID *uint64 `gorm:"column:b2b_id_currency;null" json:"currency_id"`
PercentageReduction *float64 `gorm:"type:decimal(5,2);null" json:"percentage_reduction"`
FromQuantity uint32 `gorm:"default:1" json:"from_quantity"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt *time.Time `gorm:"null" json:"created_at"`
UpdatedAt *time.Time `gorm:"null" json:"updated_at"`
ProductIDs []uint64 `gorm:"-" json:"product_ids"`
CategoryIDs []uint64 `gorm:"-" json:"category_ids"`
ProductAttributeIDs []uint64 `gorm:"-" json:"product_attribute_ids"`
CountryIDs []uint64 `gorm:"-" json:"country_ids"`
CustomerIDs []uint64 `gorm:"-" json:"customer_ids"`
}
func (SpecificPrice) TableName() string {
return "b2b_specific_price"
}

View File

@@ -2,7 +2,6 @@ package productsRepo
import (
"encoding/json"
"fmt"
"git.ma-al.com/goc_daniel/b2b/app/config"
"git.ma-al.com/goc_daniel/b2b/app/db"
@@ -10,13 +9,18 @@ import (
"git.ma-al.com/goc_daniel/b2b/app/model/dbmodel"
"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/view"
"git.ma-al.com/goc_marek/gormcol"
"github.com/WinterYukky/gorm-extra-clause-plugin/exclause"
)
type UIProductsRepo interface {
GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error)
Find(id_lang, userID uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error)
// GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error)
Find(id_lang uint, userID uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.ProductInList], error)
GetProductVariants(langID uint, productID uint, shopID uint, customerID uint, countryID uint, quantity uint) ([]view.ProductAttribute, error)
GetBase(p_id_product, p_id_shop, p_id_lang uint) (view.Product, error)
GetPrice(p_id_product uint, productAttributeID *uint, p_id_shop uint, p_id_customer uint, p_id_country uint, p_quantity uint) (view.Price, error)
GetVariants(p_id_product, p_id_shop, p_id_lang, p_id_customer, p_id_country, p_quantity uint) ([]view.ProductAttribute, error)
AddToFavorites(userID uint, productID uint) error
RemoveFromFavorites(userID uint, productID uint) error
ExistsInFavorites(userID uint, productID uint) (bool, error)
@@ -29,30 +33,78 @@ func New() UIProductsRepo {
return &ProductsRepo{}
}
func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) {
var productStr string // ← Scan as string first
func (repo *ProductsRepo) GetBase(p_id_product, p_id_shop, p_id_lang uint) (view.Product, error) {
var result view.Product
err := db.DB.Raw(`CALL get_full_product(?,?,?,?,?,?)`,
p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity).
Scan(&productStr).
Error
err := db.DB.Raw(`CALL get_product_base(?,?,?)`,
p_id_product, p_id_shop, p_id_lang).
Scan(&result).Error
if err != nil {
return nil, err
}
if !json.Valid([]byte(productStr)) {
return nil, fmt.Errorf("invalid json returned from stored procedure")
}
raw := json.RawMessage(productStr)
return &raw, nil
return result, err
}
func (repo *ProductsRepo) Find(id_lang, userID uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) {
var list []model.ProductInList
var total int64
func (repo *ProductsRepo) GetPrice(
p_id_product uint, productAttributeID *uint, p_id_shop uint, p_id_customer uint, p_id_country uint, p_quantity uint,
) (view.Price, error) {
type row struct {
Price json.RawMessage `gorm:"column:price"`
}
var r row
err := db.DB.Raw(`
SELECT fn_product_price(?,?,?,?,?,?) AS price`,
p_id_product, p_id_shop, p_id_customer, p_id_country, p_quantity, productAttributeID).
Scan(&r).Error
if err != nil {
return view.Price{}, err
}
var temp struct {
Base json.Number `json:"base"`
FinalTaxExcl json.Number `json:"final_tax_excl"`
FinalTaxIncl json.Number `json:"final_tax_incl"`
TaxRate json.Number `json:"tax_rate"`
Priority json.Number `json:"priority"`
}
if err := json.Unmarshal(r.Price, &temp); err != nil {
return view.Price{}, err
}
price := view.Price{
Base: mustParseFloat(temp.Base),
FinalTaxExcl: mustParseFloat(temp.FinalTaxExcl),
FinalTaxIncl: mustParseFloat(temp.FinalTaxIncl),
TaxRate: mustParseFloat(temp.TaxRate),
Priority: mustParseInt(temp.Priority),
}
return price, nil
}
func mustParseFloat(n json.Number) float64 {
f, _ := n.Float64()
return f
}
func mustParseInt(n json.Number) int {
i, _ := n.Int64()
return int(i)
}
func (repo *ProductsRepo) GetVariants(p_id_product, p_id_shop, p_id_lang, p_id_customer, p_id_country, p_quantity uint) ([]view.ProductAttribute, error) {
var results []view.ProductAttribute
err := db.DB.Raw(`
CALL get_product_variants(?,?,?,?,?,?)`,
p_id_product, p_id_shop, p_id_lang, p_id_customer, p_id_country, p_quantity).
Scan(&results).Error
return results, err
}
func (repo *ProductsRepo) Find(langID uint, userID uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.ProductInList], error) {
query := db.Get().
Table(gormcol.Field.Tab(dbmodel.PsProductShopCols.Active)+" AS ps").
Select(`
@@ -67,9 +119,9 @@ func (repo *ProductsRepo) Find(id_lang, userID uint, p find.Paging, filt *filter
COALESCE(f.is_favorite, 0) AS is_favorite
`, config.Get().Image.ImagePrefix).
Joins("JOIN "+dbmodel.PsProductCols.IDProduct.Tab()+" p ON p.id_product = ps.id_product").
Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", id_lang).
Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", langID).
Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1").
Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", id_lang).
Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", langID).
Joins("JOIN ps_category_product cp ON cp.id_product = ps.id_product").
Joins("LEFT JOIN variants v ON v.id_product = ps.id_product").
Joins("LEFT JOIN favorites f ON f.id_product = ps.id_product").
@@ -105,29 +157,64 @@ func (repo *ProductsRepo) Find(id_lang, userID uint, p find.Paging, filt *filter
}).
Order("ps.id_product DESC")
// Apply all filters
if filt != nil {
filt.ApplyAll(query)
}
query = query.Scopes(filt.All()...)
// run counter first as query is without limit and offset
err := query.Count(&total).Error
list, err := find.Paginate[model.ProductInList](langID, p, query)
if err != nil {
return find.Found[model.ProductInList]{}, err
return nil, err
}
return &list, nil
}
func (repo *ProductsRepo) GetProductVariants(langID uint, productID uint, shopID uint, customerID uint, countryID uint, quantity uint) ([]view.ProductAttribute, error) {
var result []view.ProductAttribute
err := db.DB.
Raw(`
CALL get_product_attributes_with_price(?, ?, ?, ?, ?, ?)
`,
langID,
productID,
shopID,
customerID,
countryID,
quantity,
).
Scan(&result).Error
err = query.
Limit(p.Limit()).
Offset(p.Offset()).
Find(&list).Error
if err != nil {
return find.Found[model.ProductInList]{}, err
return nil, err
}
return find.Found[model.ProductInList]{
Items: list,
Count: uint(total),
}, nil
return result, nil
}
func (repo *ProductsRepo) PopulateProductPrice(product *model.ProductInList, targetCustomer *model.Customer, quantity int, shopID uint) error {
row := db.Get().Raw(
"CALL get_product_price(?, ?, ?, ?, ?)",
product.ProductID,
shopID,
targetCustomer.ID,
targetCustomer.CountryID,
quantity,
).Row()
var (
id uint
base float64
excl float64
incl float64
tax float64
)
err := row.Scan(&id, &base, &excl, &incl, &tax)
if err != nil {
return err
}
product.PriceTaxExcl = excl
product.PriceTaxIncl = incl
return nil
}
func (repo *ProductsRepo) AddToFavorites(userID uint, productID uint) error {

View File

@@ -0,0 +1,247 @@
package specificPriceRepo
import (
"context"
"errors"
"time"
"git.ma-al.com/goc_daniel/b2b/app/db"
"git.ma-al.com/goc_daniel/b2b/app/model"
"gorm.io/gorm"
)
type UISpecificPriceRepo interface {
Create(ctx context.Context, pr *model.SpecificPrice) error
Update(ctx context.Context, pr *model.SpecificPrice) error
Delete(ctx context.Context, id uint64) error
GetByID(ctx context.Context, id uint64) (*model.SpecificPrice, error)
List(ctx context.Context) ([]*model.SpecificPrice, error)
SetActive(ctx context.Context, id uint64, active bool) error
}
type SpecificPriceRepo struct{}
func New() UISpecificPriceRepo {
return &SpecificPriceRepo{}
}
func (repo *SpecificPriceRepo) Create(ctx context.Context, pr *model.SpecificPrice) error {
return db.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
pr.CreatedAt = &now
if err := tx.Create(pr).Error; err != nil {
return err
}
return repo.insertRelations(tx, pr)
})
}
func (repo *SpecificPriceRepo) Update(ctx context.Context, pr *model.SpecificPrice) error {
return db.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
pr.UpdatedAt = &now
if err := tx.Save(pr).Error; err != nil {
return err
}
if err := repo.clearRelations(tx, pr.ID); err != nil {
return err
}
return repo.insertRelations(tx, pr)
})
}
func (repo *SpecificPriceRepo) GetByID(ctx context.Context, id uint64) (*model.SpecificPrice, error) {
var pr model.SpecificPrice
err := db.DB.WithContext(ctx).Where("id = ?", id).First(&pr).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
if err := repo.loadRelations(ctx, &pr); err != nil {
return nil, err
}
return &pr, nil
}
func (repo *SpecificPriceRepo) List(ctx context.Context) ([]*model.SpecificPrice, error) {
var specificPrices []*model.SpecificPrice
err := db.DB.WithContext(ctx).Find(&specificPrices).Error
if err != nil {
return nil, err
}
for i := range specificPrices {
if err := repo.loadRelations(ctx, specificPrices[i]); err != nil {
return nil, err
}
}
return specificPrices, nil
}
func (repo *SpecificPriceRepo) SetActive(ctx context.Context, id uint64, active bool) error {
return db.DB.WithContext(ctx).Model(&model.SpecificPrice{}).Where("id = ?", id).Update("is_active", active).Error
}
func (repo *SpecificPriceRepo) Delete(ctx context.Context, id uint64) error {
return db.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("DELETE FROM b2b_specific_price_product WHERE b2b_specific_price_id = ?", id).Error; err != nil {
return err
}
if err := tx.Exec("DELETE FROM b2b_specific_price_category WHERE b2b_specific_price_id = ?", id).Error; err != nil {
return err
}
if err := tx.Exec("DELETE FROM b2b_specific_price_product_attribute WHERE b2b_specific_price_id = ?", id).Error; err != nil {
return err
}
if err := tx.Exec("DELETE FROM b2b_specific_price_country WHERE b2b_specific_price_id = ?", id).Error; err != nil {
return err
}
if err := tx.Exec("DELETE FROM b2b_specific_price_customer WHERE b2b_specific_price_id = ?", id).Error; err != nil {
return err
}
if err := tx.Delete(&model.SpecificPrice{}, "id = ?", id).Error; err != nil {
return err
}
return nil
})
}
func (repo *SpecificPriceRepo) insertRelations(tx *gorm.DB, pr *model.SpecificPrice) error {
if len(pr.ProductIDs) > 0 {
for _, productID := range pr.ProductIDs {
if err := tx.Exec(`
INSERT INTO b2b_specific_price_product (b2b_specific_price_id, id_product) VALUES (?, ?)
`, pr.ID, productID).Error; err != nil {
return err
}
}
}
if len(pr.CategoryIDs) > 0 {
for _, categoryID := range pr.CategoryIDs {
if err := tx.Exec(`
INSERT INTO b2b_specific_price_category (b2b_specific_price_id, id_category) VALUES (?, ?)
`, pr.ID, categoryID).Error; err != nil {
return err
}
}
}
if len(pr.ProductAttributeIDs) > 0 {
for _, attrID := range pr.ProductAttributeIDs {
if err := tx.Exec(`
INSERT INTO b2b_specific_price_product_attribute (b2b_specific_price_id, id_product_attribute) VALUES (?, ?)
`, pr.ID, attrID).Error; err != nil {
return err
}
}
}
if len(pr.CountryIDs) > 0 {
for _, countryID := range pr.CountryIDs {
if err := tx.Exec(`
INSERT INTO b2b_specific_price_country (b2b_specific_price_id, b2b_id_country) VALUES (?, ?)
`, pr.ID, countryID).Error; err != nil {
return err
}
}
}
if len(pr.CustomerIDs) > 0 {
for _, customerID := range pr.CustomerIDs {
if err := tx.Exec(`
INSERT INTO b2b_specific_price_customer (b2b_specific_price_id, b2b_id_customer) VALUES (?, ?)
`, pr.ID, customerID).Error; err != nil {
return err
}
}
}
return nil
}
func (repo *SpecificPriceRepo) clearRelations(tx *gorm.DB, id uint64) error {
if err := tx.Exec("DELETE FROM b2b_specific_price_product WHERE b2b_specific_price_id = ?", id).Error; err != nil {
return err
}
if err := tx.Exec("DELETE FROM b2b_specific_price_category WHERE b2b_specific_price_id = ?", id).Error; err != nil {
return err
}
if err := tx.Exec("DELETE FROM b2b_specific_price_product_attribute WHERE b2b_specific_price_id = ?", id).Error; err != nil {
return err
}
if err := tx.Exec("DELETE FROM b2b_specific_price_country WHERE b2b_specific_price_id = ?", id).Error; err != nil {
return err
}
if err := tx.Exec("DELETE FROM b2b_specific_price_customer WHERE b2b_specific_price_id = ?", id).Error; err != nil {
return err
}
return nil
}
func (repo *SpecificPriceRepo) loadRelations(ctx context.Context, pr *model.SpecificPrice) error {
var err error
var productIDs []struct {
IDProduct uint64 `gorm:"column:id_product"`
}
if err = db.DB.WithContext(ctx).Table("b2b_specific_price_product").Where("b2b_specific_price_id = ?", pr.ID).Select("id_product").Scan(&productIDs).Error; err != nil {
return err
}
for _, p := range productIDs {
pr.ProductIDs = append(pr.ProductIDs, p.IDProduct)
}
var categoryIDs []struct {
IDCategory uint64 `gorm:"column:id_category"`
}
if err = db.DB.WithContext(ctx).Table("b2b_specific_price_category").Where("b2b_specific_price_id = ?", pr.ID).Select("id_category").Scan(&categoryIDs).Error; err != nil {
return err
}
for _, c := range categoryIDs {
pr.CategoryIDs = append(pr.CategoryIDs, c.IDCategory)
}
var attrIDs []struct {
IDAttr uint64 `gorm:"column:id_product_attribute"`
}
if err = db.DB.WithContext(ctx).Table("b2b_specific_price_product_attribute").Where("b2b_specific_price_id = ?", pr.ID).Select("id_product_attribute").Scan(&attrIDs).Error; err != nil {
return err
}
for _, a := range attrIDs {
pr.ProductAttributeIDs = append(pr.ProductAttributeIDs, a.IDAttr)
}
var countryIDs []struct {
IDCountry uint64 `gorm:"column:b2b_id_country"`
}
if err = db.DB.WithContext(ctx).Table("b2b_specific_price_country").Where("b2b_specific_price_id = ?", pr.ID).Select("b2b_id_country").Scan(&countryIDs).Error; err != nil {
return err
}
for _, c := range countryIDs {
pr.CountryIDs = append(pr.CountryIDs, c.IDCountry)
}
var customerIDs []struct {
IDCustomer uint64 `gorm:"column:b2b_id_customer"`
}
if err = db.DB.WithContext(ctx).Table("b2b_specific_price_customer").Where("b2b_specific_price_id = ?", pr.ID).Select("b2b_id_customer").Scan(&customerIDs).Error; err != nil {
return err
}
for _, c := range customerIDs {
pr.CustomerIDs = append(pr.CustomerIDs, c.IDCustomer)
}
return nil
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// JWTClaims represents the JWT claims
@@ -436,7 +437,7 @@ func (s *AuthService) RevokeAllRefreshTokens(userID uint) {
// GetUserByID retrieves a user by ID
func (s *AuthService) GetUserByID(userID uint) (*model.Customer, error) {
var user model.Customer
if err := s.db.Preload("Role.Permissions").First(&user, userID).Error; err != nil {
if err := s.db.Preload("Role.Permissions").Preload(clause.Associations).First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, responseErrors.ErrUserNotFound
}

View File

@@ -2,6 +2,7 @@ package productService
import (
"encoding/json"
"errors"
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/repos/productsRepo"
@@ -9,6 +10,7 @@ import (
"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/responseErrors"
"git.ma-al.com/goc_daniel/b2b/app/view"
)
type ProductService struct {
@@ -21,17 +23,108 @@ func New() *ProductService {
}
}
func (s *ProductService) GetJSON(p_id_product, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) {
products, err := s.productsRepo.GetJSON(p_id_product, constdata.SHOP_ID, p_id_lang, p_id_customer, b2b_id_country, p_quantity)
func (s *ProductService) Get(
p_id_product, p_id_lang, p_id_customer, b2b_id_country, p_quantity uint,
) (*json.RawMessage, error) {
product, err := s.productsRepo.GetBase(p_id_product, constdata.SHOP_ID, p_id_lang)
if err != nil {
return products, err
return nil, err
}
return products, nil
price, err := s.productsRepo.GetPrice(p_id_product, nil, constdata.SHOP_ID, p_id_customer, b2b_id_country, p_quantity)
if err != nil {
return nil, err
}
variants, err := s.productsRepo.GetVariants(p_id_product, constdata.SHOP_ID, p_id_lang, p_id_customer, b2b_id_country, p_quantity)
if err != nil {
return nil, err
}
result := view.ProductFull{
Product: product,
Price: price,
Variants: variants,
}
if len(variants) > 0 {
result.Variants = variants
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return nil, err
}
raw := json.RawMessage(jsonBytes)
return &raw, nil
}
func (s *ProductService) Find(id_lang, userID uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.ProductInList], error) {
return s.productsRepo.Find(id_lang, userID, p, filters)
func (s *ProductService) Find(
idLang uint,
userID uint,
p find.Paging,
filters *filters.FiltersList,
customer *model.Customer,
quantity uint,
shopID uint,
) (*find.Found[model.ProductInList], error) {
if customer == nil || customer.Country == nil {
return nil, errors.New("customer is nil or missing fields")
}
found, err := s.productsRepo.Find(idLang, userID, p, filters)
if err != nil {
return nil, err
}
// 1. collect simple products (no variants)
simpleProductIndexes := make([]int, 0, len(found.Items))
for i := range found.Items {
if found.Items[i].VariantsNumber <= 0 {
simpleProductIndexes = append(simpleProductIndexes, i)
}
}
// 2. resolve prices ONLY for simple products
for _, i := range simpleProductIndexes {
price, err := s.productsRepo.GetPrice(
found.Items[i].ProductID,
nil,
shopID,
customer.ID,
customer.CountryID,
quantity,
)
if err != nil {
return nil, err
}
found.Items[i].PriceTaxExcl = price.FinalTaxExcl
found.Items[i].PriceTaxIncl = price.FinalTaxIncl
}
return found, nil
}
func (s *ProductService) GetProductAttributes(
langID uint,
productID uint,
shopID uint,
customerID uint,
countryID uint,
quantity uint,
) ([]view.ProductAttribute, error) {
variants, err := s.productsRepo.GetVariants(productID, constdata.SHOP_ID, langID, customerID, countryID, quantity)
if err != nil {
return nil, err
}
return variants, nil
}
func (s *ProductService) AddToFavorites(userID uint, productID uint) error {

View File

@@ -0,0 +1,124 @@
package specificPriceService
import (
"context"
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/repos/specificPriceRepo"
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
)
type SpecificPriceService struct {
specificPriceRepo specificPriceRepo.UISpecificPriceRepo
}
func New() *SpecificPriceService {
return &SpecificPriceService{
specificPriceRepo: specificPriceRepo.New(),
}
}
func (s *SpecificPriceService) Create(ctx context.Context, pr *model.SpecificPrice) (*model.SpecificPrice, error) {
if err := s.validateRequest(pr); err != nil {
return nil, err
}
if err := s.specificPriceRepo.Create(ctx, pr); err != nil {
return nil, err
}
return pr, nil
}
func (s *SpecificPriceService) Update(ctx context.Context, id uint64, pr *model.SpecificPrice) (*model.SpecificPrice, error) {
existing, err := s.specificPriceRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if existing == nil {
return nil, responseErrors.ErrSpecificPriceNotFound
}
if err := s.validateUpdateRequest(pr); err != nil {
return nil, err
}
pr.ID = id
if err := s.specificPriceRepo.Update(ctx, pr); err != nil {
return nil, err
}
return pr, nil
}
func (s *SpecificPriceService) GetByID(ctx context.Context, id uint64) (*model.SpecificPrice, error) {
pr, err := s.specificPriceRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if pr == nil {
return nil, responseErrors.ErrSpecificPriceNotFound
}
return pr, nil
}
func (s *SpecificPriceService) List(ctx context.Context) ([]*model.SpecificPrice, error) {
return s.specificPriceRepo.List(ctx)
}
func (s *SpecificPriceService) SetActive(ctx context.Context, id uint64, active bool) error {
pr, err := s.specificPriceRepo.GetByID(ctx, id)
if err != nil {
return err
}
if pr == nil {
return responseErrors.ErrSpecificPriceNotFound
}
return s.specificPriceRepo.SetActive(ctx, id, active)
}
func (s *SpecificPriceService) Delete(ctx context.Context, id uint64) error {
pr, err := s.specificPriceRepo.GetByID(ctx, id)
if err != nil {
return err
}
if pr == nil {
return responseErrors.ErrSpecificPriceNotFound
}
return s.specificPriceRepo.Delete(ctx, id)
}
func (s *SpecificPriceService) validateRequest(pr *model.SpecificPrice) error {
if pr.ReductionType != "amount" && pr.ReductionType != "percentage" {
return responseErrors.ErrInvalidReductionType
}
if pr.ReductionType == "percentage" && pr.PercentageReduction == nil {
return responseErrors.ErrPercentageRequired
}
if pr.ReductionType == "amount" && pr.Price == nil {
return responseErrors.ErrPriceRequired
}
return nil
}
func (s *SpecificPriceService) validateUpdateRequest(pr *model.SpecificPrice) error {
if pr.ReductionType != "" && pr.ReductionType != "amount" && pr.ReductionType != "percentage" {
return responseErrors.ErrInvalidReductionType
}
if pr.ReductionType == "percentage" && pr.PercentageReduction == nil {
return responseErrors.ErrPercentageRequired
}
if pr.ReductionType == "amount" && pr.Price == nil {
return responseErrors.ErrPriceRequired
}
return nil
}

View File

@@ -3,6 +3,7 @@ package constdata
// PASSWORD_VALIDATION_REGEX is used by the frontend (JavaScript supports lookaheads).
const PASSWORD_VALIDATION_REGEX = `^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{10,}$`
const SHOP_ID = 1
const DEFAULT_PRODUCT_QUANTITY = 1
const SHOP_DEFAULT_LANGUAGE = 1
const ADMIN_NOTIFICATION_LANGUAGE = 2

View File

@@ -66,6 +66,11 @@ var (
ErrUserHasNoSuchCart = errors.New("user does not have cart with given id")
ErrProductOrItsVariationDoesNotExist = errors.New("product or its variation with given ids does not exist")
// Typed errors for price reduction handler
ErrInvalidReductionType = errors.New("invalid reduction type: must be 'amount' or 'percentage'")
ErrPercentageRequired = errors.New("percentage_reduction required when reduction_type is percentage")
ErrPriceRequired = errors.New("price required when reduction_type is amount")
ErrSpecificPriceNotFound = errors.New("price reduction not found")
// Typed errors for storage
ErrAccessDenied = errors.New("access denied!")
ErrFolderDoesNotExist = errors.New("folder does not exist")
@@ -207,6 +212,15 @@ func GetErrorCode(c fiber.Ctx, err error) string {
case errors.Is(err, ErrMissingFileFieldDocument):
return i18n.T_(c, "error.err_missing_file_field_document")
case errors.Is(err, ErrInvalidReductionType):
return i18n.T_(c, "error.invalid_reduction_type")
case errors.Is(err, ErrPercentageRequired):
return i18n.T_(c, "error.percentage_required")
case errors.Is(err, ErrPriceRequired):
return i18n.T_(c, "error.price_required")
case errors.Is(err, ErrSpecificPriceNotFound):
return i18n.T_(c, "error.price_reduction_not_found")
case errors.Is(err, ErrJSONBody):
return i18n.T_(c, "error.err_json_body")
@@ -268,6 +282,9 @@ func GetErrorStatus(err error) int {
errors.Is(err, ErrMaxAmtOfCartsReached),
errors.Is(err, ErrUserHasNoSuchCart),
errors.Is(err, ErrProductOrItsVariationDoesNotExist),
errors.Is(err, ErrInvalidReductionType),
errors.Is(err, ErrPercentageRequired),
errors.Is(err, ErrPriceRequired),
errors.Is(err, ErrAccessDenied),
errors.Is(err, ErrFolderDoesNotExist),
errors.Is(err, ErrFileDoesNotExist),
@@ -279,6 +296,8 @@ func GetErrorStatus(err error) int {
errors.Is(err, ErrInvalidCountryID),
errors.Is(err, ErrInvalidAddressJSON):
return fiber.StatusBadRequest
case errors.Is(err, ErrSpecificPriceNotFound):
return fiber.StatusNotFound
case errors.Is(err, ErrEmailExists):
return fiber.StatusConflict
case errors.Is(err, ErrAIResponseFail),

98
app/view/product.go Normal file
View File

@@ -0,0 +1,98 @@
package view
import (
"encoding/json"
"time"
)
type ProductAttribute struct {
IDProductAttribute uint `gorm:"column:id_product_attribute" json:"id_product_attribute"`
Reference string `gorm:"column:reference" json:"reference"`
BasePrice float64 `gorm:"column:base_price" json:"base_price"`
PriceTaxExcl float64 `gorm:"column:price_tax_excl" json:"price_tax_excl"`
PriceTaxIncl float64 `gorm:"column:price_tax_incl" json:"price_tax_incl"`
Quantity int64 `gorm:"column:quantity" json:"quantity"`
Attributes json.RawMessage `gorm:"column:attributes" json:"attributes"`
}
type Price struct {
Base float64 `json:"base"`
FinalTaxExcl float64 `json:"final_tax_excl"`
FinalTaxIncl float64 `json:"final_tax_incl"`
TaxRate float64 `json:"tax_rate"`
Priority int `json:"priority"` // or string
}
type Variant struct {
ID uint `json:"id_product_attribute"`
Reference string `json:"reference"`
BasePrice float64 `json:"base_price"`
FinalExcl float64 `json:"final_tax_excl"`
FinalIncl float64 `json:"final_tax_incl"`
Stock int `json:"stock"`
}
type ProductFull struct {
Product Product `json:"product"`
Price Price `json:"price"`
Variants []ProductAttribute `json:"variants,omitempty"`
}
type Product struct {
ID uint `gorm:"column:id" json:"id"`
Reference string `gorm:"column:reference" json:"reference"`
SupplierReference string `gorm:"column:supplier_reference" json:"supplier_reference,omitempty"`
EAN13 string `gorm:"column:ean13" json:"ean13,omitempty"`
UPC string `gorm:"column:upc" json:"upc,omitempty"`
ISBN string `gorm:"column:isbn" json:"isbn,omitempty"`
// Basic Price (from product table)
BasePrice float64 `gorm:"column:base_price" json:"base_price"`
WholesalePrice float64 `gorm:"column:wholesale_price" json:"wholesale_price,omitempty"`
Unity string `gorm:"column:unity" json:"unity,omitempty"`
UnitPriceRatio float64 `gorm:"column:unit_price_ratio" json:"unit_price_ratio,omitempty"`
// Stock & Availability
Quantity int `gorm:"column:quantity" json:"quantity"`
MinimalQuantity int `gorm:"column:minimal_quantity" json:"minimal_quantity"`
AvailableForOrder bool `gorm:"column:available_for_order" json:"available_for_order"`
AvailableDate string `gorm:"column:available_date" json:"available_date,omitempty"`
OutOfStockBehavior int `gorm:"column:out_of_stock_behavior" json:"out_of_stock_behavior"`
// Flags
OnSale bool `gorm:"column:on_sale" json:"on_sale"`
ShowPrice bool `gorm:"column:show_price" json:"show_price"`
Condition string `gorm:"column:condition" json:"condition"`
IsVirtual bool `gorm:"column:is_virtual" json:"is_virtual"`
// Physical
Weight float64 `gorm:"column:weight" json:"weight"`
Width float64 `gorm:"column:width" json:"width"`
Height float64 `gorm:"column:height" json:"height"`
Depth float64 `gorm:"column:depth" json:"depth"`
AdditionalShippingCost float64 `gorm:"column:additional_shipping_cost" json:"additional_shipping_cost,omitempty"`
// Delivery
DeliveryDays int `gorm:"column:delivery_days" json:"delivery_days,omitempty"`
// Status
Active bool `gorm:"column:active" json:"active"`
Visibility string `gorm:"column:visibility" json:"visibility"`
Indexed bool `gorm:"column:indexed" json:"indexed"`
// Timestamps
DateAdd time.Time `gorm:"column:date_add" json:"date_add"`
DateUpd time.Time `gorm:"column:date_upd" json:"date_upd"`
// Language fields
Name string `gorm:"column:name" json:"name"`
Description string `gorm:"column:description" json:"description"`
DescriptionShort string `gorm:"column:description_short" json:"description_short"`
// Relations
Manufacturer string `gorm:"column:manufacturer" json:"manufacturer"`
Category string `gorm:"column:category" json:"category"`
IsFavorite bool `gorm:"column:is_favorite" json:"is_favorite"`
}

11
bo/components.d.ts vendored
View File

@@ -11,12 +11,16 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ButtonGoToProfile: typeof import('./src/components/customer-management/ButtonGoToProfile.vue')['default']
CartDetails: typeof import('./src/components/customer/CartDetails.vue')['default']
CategoryMenu: typeof import('./src/components/inner/categoryMenu.vue')['default']
CategoryMenu: typeof import('./src/components/inner/CategoryMenu.vue')['default']
copy: typeof import('./src/components/admin/ProductDetailView copy.vue')['default']
CountryCurrencySwitch: typeof import('./src/components/inner/CountryCurrencySwitch.vue')['default']
Cs_PrivacyPolicyView: typeof import('./src/components/terms/cs_PrivacyPolicyView.vue')['default']
Cs_TermsAndConditionsView: typeof import('./src/components/terms/cs_TermsAndConditionsView.vue')['default']
En_PrivacyPolicyView: typeof import('./src/components/terms/en_PrivacyPolicyView.vue')['default']
En_TermsAndConditionsView: typeof import('./src/components/terms/en_TermsAndConditionsView.vue')['default']
FavoriteProducts: typeof import('./src/components/admin/FavoriteProducts.vue')['default']
LangSwitch: typeof import('./src/components/inner/LangSwitch.vue')['default']
PageAddresses: typeof import('./src/components/customer/PageAddresses.vue')['default']
PageCarts: typeof import('./src/components/customer/PageCarts.vue')['default']
@@ -33,8 +37,10 @@ declare module 'vue' {
'ProductDetailView copy': typeof import('./src/components/admin/ProductDetailView copy.vue')['default']
ProductEditor: typeof import('./src/components/inner/ProductEditor.vue')['default']
ProductVariants: typeof import('./src/components/customer/components/ProductVariants.vue')['default']
Profile: typeof import('./src/components/customer-management/Profile.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StorageFileBrowser: typeof import('./src/components/customer/StorageFileBrowser.vue')['default']
ThemeSwitch: typeof import('./src/components/inner/ThemeSwitch.vue')['default']
TopBar: typeof import('./src/components/TopBar.vue')['default']
TopBarLogin: typeof import('./src/components/TopBarLogin.vue')['default']
@@ -57,9 +63,12 @@ declare module 'vue' {
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']
USelectMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
UsersList: typeof import('./src/components/admin/UsersList.vue')['default']
UsersSearch: typeof import('./src/components/admin/UsersSearch.vue')['default']
USidebar: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Sidebar.vue')['default']
UTable: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default']
UTabs: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
UTextarea: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Textarea.vue')['default']
UTree: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Tree.vue')['default']
}
}

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { TooltipProvider } from 'reka-ui'
import { RouterView } from 'vue-router'
import { useAuthStore } from './stores/customer/auth'
const authStore = useAuthStore()
</script>
<template>
@@ -12,35 +15,3 @@ import { RouterView } from 'vue-router'
</template>
<!-- <template>
<component :is="layoutComponent">
<Suspense>
<RouterView />
</Suspense>
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import DefaultLayout from '@/layouts/default.vue'
import EmptyLayout from '@/layouts/empty.vue'
const route = useRoute()
const layouts = {
default: DefaultLayout,
auth: EmptyLayout
}
console.log(route.fullPath)
console.log(route.name)
console.log(route.matched)
const layoutComponent = computed(() => {
console.log(route.meta);
return layouts[route.meta.layout as keyof typeof layouts] || DefaultLayout
})
</script> -->

View File

@@ -47,7 +47,7 @@ export const uiOptions: NuxtUIOptions = {
table: {
slots: {
base: 'border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! bg-(--second-light) dark:bg-(--main-dark)',
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!',
}
},

View File

@@ -38,7 +38,7 @@
import { useFetchJson } from '@/composable/useFetchJson'
import LangSwitch from './inner/LangSwitch.vue'
import ThemeSwitch from './inner/ThemeSwitch.vue'
import { useAuthStore } from '@/stores/auth'
import { useAuthStore } from '@/stores/customer/auth'
import { computed, ref } from 'vue'
import { currentLang } from '@/router/langs'
import type { LabelTrans, TopMenuItem } from '@/types'

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import LangSwitch from './inner/LangSwitch.vue'
import ThemeSwitch from './inner/ThemeSwitch.vue'
import { useAuthStore } from '@/stores/auth'
import { useAuthStore } from '@/stores/customer/auth'
const authStore = useAuthStore()
</script>

View File

@@ -0,0 +1,12 @@
<template>
<component :is="Default || 'div'">
<div>
</div>
</component>
</template>
<script setup lang="ts">
import Default from '@/layouts/default.vue';
</script>

View File

@@ -141,7 +141,7 @@ async function fetchProductList() {
if (route.params.category_id)
params.append('category_id', String(route.params.category_id))
const url = `/api/v1/restricted/list/list-products?elems=${perPage.value}&${params.toString()}`
const url = `/api/v1/restricted/product/list?elems=${perPage.value}&${params.toString()}`
try {
const response = await useFetchJson<ApiResponse>(url)
productsList.value = response.items || []
@@ -161,7 +161,7 @@ function goToProduct(productId: number, linkRewrite: string) {
}
localStorage.setItem('back_from_product', JSON.stringify(path))
router.push({
name: 'customer-product-details',
name: 'admin-product-details',
params: { product_id: productId, link_rewrite: linkRewrite }
})
}

View File

@@ -198,7 +198,7 @@ import { useEditable } from '@/composable/useConteditable';
import Default from '@/layouts/default.vue';
import { langs } from '@/router/langs';
import { useProductStore } from '@/stores/product';
import { useSettingsStore } from '@/stores/settings';
import { useSettingsStore } from '@/stores/admin/settings';
import type { EditorToolbarItem } from '@nuxt/ui';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';

View File

@@ -89,6 +89,14 @@
}" />
</div>
</div>
<div class="" v-if="isTranslations">
<p>Link rewrite:</p>
<UTextarea :rows="1" v-model="productStore.productDescription.link_rewrite" autoresize :ui="{
root: 'w-full',
base: 'bg-inherit!',
}" />
</div>
<div class="" v-if="isTranslations">
<p>Link rewrite:</p>
@@ -152,8 +160,8 @@
<script setup lang="ts">
import Default from '@/layouts/default.vue';
import { langs } from '@/router/langs';
import { useProductStore } from '@/stores/product';
import { useSettingsStore } from '@/stores/settings';
import { useProductStore } from '@/stores/admin/product';
import { useSettingsStore } from '@/stores/admin/settings';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ProductEditor from '../inner/ProductEditor.vue';

View File

@@ -0,0 +1,211 @@
<template>
<component :is="Default || 'div'">
<div class="flex flex-col md:flex-row gap-10">
<div class="w-full flex flex-col items-center gap-4">
<UTable :data="usersList" :columns="columns" class="flex-1 w-full"
:ui="{ root: 'max-w-100wv overflow-auto!' }" />
<UPagination v-model:page="page" :total="total" :items-per-page="perPage" />
</div>
</div>
</component>
</template>
<script setup lang="ts">
import Default from '@/layouts/default.vue'
import { ref, computed, watch, resolveComponent, h } from 'vue'
import { useFetchJson } from '@/composable/useFetchJson'
import { useRoute, useRouter } from 'vue-router'
import CategoryMenu from '../inner/CategoryMenu.vue'
import type { TableColumn } from '@nuxt/ui'
import type { Customer } from '@/types/user'
const router = useRouter()
const route = useRoute()
const perPage = ref(15)
const page = computed({
get: () => Number(route.query.p) || 1,
set: (val: number) => {
router.push({ query: { ...route.query, p: val } })
}
})
const sortField = computed({
get: () => [
route.query.sort as string | undefined,
route.query.direction as 'asc' | 'desc' | undefined
],
set: ([sort]: [string, 'asc' | 'desc']) => {
const currentSort = route.query.sort as string | undefined
const currentDirection = route.query.direction as 'asc' | 'desc' | undefined
const query = { ...route.query }
if (currentSort === sort) {
if (currentDirection === 'asc') query.direction = 'desc'
else if (currentDirection === 'desc') {
delete query.sort
delete query.direction
} else {
query.direction = 'asc'
query.sort = sort
}
} else {
query.sort = sort
query.direction = 'asc'
}
router.push({ query })
}
})
const filters = computed<Record<string, string>>({
get: () => {
const q = { ...route.query }
delete q.page
delete q.sort
delete q.direction
return q as Record<string, string>
},
set: (val) => {
const baseQuery = { ...route.query }
Object.keys(baseQuery).forEach(k => {
if (!['page', 'sort', 'direction'].includes(k)) delete baseQuery[k]
})
router.push({ query: { ...baseQuery, ...val, page: 1 } })
}
})
function debounce(fn: Function, delay = 400) {
let t: any
return (...args: any[]) => {
clearTimeout(t)
t = setTimeout(() => fn(...args), delay)
}
}
const updateFilter = debounce((columnId: string, val: string) => {
const newFilters = { ...filters.value }
if (val) newFilters[columnId] = val
else delete newFilters[columnId]
filters.value = newFilters
}, 400)
const usersList = ref<Customer[]>([])
const total = ref(0)
const loading = ref(true)
const error = ref<string | null>(null)
async function fetchUsersList() {
loading.value = true
error.value = null
const params = new URLSearchParams(route.query as any).toString()
const url = `/api/v1/restricted/customer/list?elems=${perPage.value}&${params}`
try {
const res = await useFetchJson<{ items: { items: Customer[] }; count: number }>(url)
usersList.value = res.items?.items || []
total.value = res.count || 0
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load users'
} finally {
loading.value = false
}
}
function getIcon(name: string) {
if (sortField.value[0] === name) {
if (sortField.value[1] === 'asc') return 'i-lucide-arrow-up-narrow-wide'
if (sortField.value[1] === 'desc') return 'i-lucide-arrow-down-wide-narrow'
}
return 'i-lucide-arrow-up-down'
}
const UInput = resolveComponent('UInput')
const UIcon = resolveComponent('UIcon')
const UButton = resolveComponent('UButton')
const columns: TableColumn<Customer>[] = [
{
accessorKey: 'user_id',
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
h('div', {
class: 'flex items-center gap-2 cursor-pointer',
onClick: () => (sortField.value = ['user_id', 'asc'])
}, [h('span', 'Client ID'), h(UIcon, { name: getIcon('user_id') })]),
h(UInput, {
placeholder: 'Search...',
modelValue: filters.value[column.id] ?? '',
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
size: 'xs'
})
]),
cell: ({ row }) => `#${row.getValue('user_id')}`
},
{
accessorKey: 'name',
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
h('div', {
class: 'flex items-center gap-2 cursor-pointer',
onClick: () => (sortField.value = ['last_name, first_name', 'asc'])
}, [h('span', 'Name/Surname'), h(UIcon, { name: getIcon('name') })]),
h(UInput, {
placeholder: 'Search...',
modelValue: filters.value[column.id] ?? '',
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
size: 'xs'
})
]),
cell: ({ row }) => `${row.original.first_name} ${row.original.last_name}`
},
{
accessorKey: 'email',
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
h('div', {
class: 'flex items-center gap-2 cursor-pointer',
onClick: () => (sortField.value = ['email', 'asc'])
}, [h('span', 'Email'), h(UIcon, { name: getIcon('email') })]),
h(UInput, {
placeholder: 'Search...',
modelValue: filters.value[column.id] ?? '',
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
size: 'xs'
})
]),
cell: ({ row }) => row.getValue('email')
},
{
accessorKey: 'count',
header: '',
cell: ({ row }) => {
return h(UButton, {
// onClick: () => {
// goToProduct(row.original.product_id, row.original.link_rewrite)
// },
class: 'cursor-pointer',
color: 'info',
variant: 'soft'
}, () => 'Manage')
},
},
{
accessorKey: 'profile',
header: '',
cell: ({ row }) => {
const userId = row.original.user_id
return h(UButton, {
color: 'info',
size: 'sm',
variant: 'soft',
onClick: () => {
router.push({
name: 'customer-management-profile',
params: { user_id: userId }
})
}
}, () => 'Go to profile')
}
}
]
watch(() => route.query, fetchUsersList, { immediate: true })
</script>

View File

@@ -0,0 +1,245 @@
<template>
<component :is="Default">
<div class="pt-70! flex flex-col items-center justify-center bg-gray-50 dark:bg-(--main-dark)">
<h1 class="text-6xl font-bold text-black dark:text-white mb-14">Search Users</h1>
<div class="w-full max-w-4xl">
<UInput icon="i-lucide-search" type="text" placeholder="Type user name or ID..." v-model="searchQuery"
class="w-full!" :ui="{ base: 'py-4! rounded-full!' }" />
</div>
<p v-if="loading">Loading...</p>
<p v-else-if="error" class="text-red-600">{{ error }}</p>
<div v-else-if="clients.length" class="w-full max-w-4xl mt-7">
<UTable :columns="columns" :data="clients" :ui="{ root: 'w-full!' }" />
</div>
<p v-else-if="searchQuery.length" class="pt-4 text-gray-700">
No users found with that name or ID
</p>
</div>
</component>
</template>
<script setup lang="ts">
import { ref, computed, watch, resolveComponent, h } from 'vue'
import Default from '@/layouts/default.vue';
import type { TableColumn } from '@nuxt/ui';
import { useRoute, useRouter } from 'vue-router';
import { useFetchJson } from '@/composable/useFetchJson';
interface Customer {
user_id: number;
email: string;
first_name: string;
last_name: string;
}
const searchQuery = ref('');
const clients = ref<Customer[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const limit = ref(10);
const page = ref(1);
function debounce(fn: Function, delay = 400) {
let t: any;
return (...args: any[]) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
};
}
const searchClients = async () => {
if (!searchQuery.value) {
clients.value = [];
return;
}
loading.value = true;
error.value = null;
try {
const result = await useFetchJson(
`/api/v1/restricted/customer/list?search=${encodeURIComponent(searchQuery.value)}&elems=${limit.value}&page=${page.value}`
);
clients.value = result.items?.items || [];
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch clients';
clients.value = [];
} finally {
loading.value = false;
}
};
watch(searchQuery, debounce(searchClients, 300));
const router = useRouter()
const route = useRoute()
const UInput = resolveComponent('UInput')
const UIcon = resolveComponent('UIcon')
const UButton = resolveComponent('UButton')
const sortField = computed({
get: () => [
route.query.sort as string | undefined,
route.query.direction as 'asc' | 'desc' | undefined
],
set: ([sort]: [string, 'asc' | 'desc']) => {
const currentSort = route.query.sort as string | undefined
const currentDirection = route.query.direction as 'asc' | 'desc' | undefined
const query = { ...route.query }
if (currentSort === sort) {
if (currentDirection === 'asc') query.direction = 'desc'
else if (currentDirection === 'desc') {
delete query.sort
delete query.direction
} else {
query.direction = 'asc'
query.sort = sort
}
} else {
query.sort = sort
query.direction = 'asc'
}
router.push({ query })
}
})
function getIcon(name: string) {
if (sortField.value[0] === name) {
if (sortField.value[1] === 'asc') return 'i-lucide-arrow-up-narrow-wide'
if (sortField.value[1] === 'desc') return 'i-lucide-arrow-down-wide-narrow'
}
return 'i-lucide-arrow-up-down'
}
const filters = computed<Record<string, string>>({
get: () => {
const q = { ...route.query }
delete q.page
delete q.sort
delete q.direction
return q as Record<string, string>
},
set: (val) => {
const baseQuery = { ...route.query }
Object.keys(baseQuery).forEach(k => {
if (!['page', 'sort', 'direction'].includes(k)) delete baseQuery[k]
})
router.push({ query: { ...baseQuery, ...val, page: 1 } })
}
})
const updateFilter = debounce((columnId: string, val: string) => {
const newFilters = { ...filters.value }
if (val) newFilters[columnId] = val
else delete newFilters[columnId]
filters.value = newFilters
}, 400)
const columns: TableColumn<Customer>[] = [
{
accessorKey: 'user_id',
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
h('div', {
class: 'flex items-center gap-2 cursor-pointer',
onClick: () => (sortField.value = ['user_id', 'asc'])
}, [h('span', 'Client ID'), h(UIcon, { name: getIcon('user_id') })]),
h(UInput, {
placeholder: 'Search...',
modelValue: filters.value[column.id] ?? '',
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
size: 'xs'
})
]),
cell: ({ row }) => `#${row.getValue('user_id')}`
},
{
accessorKey: 'name',
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
h('div', {
class: 'flex items-center gap-2 cursor-pointer',
onClick: () => (sortField.value = ['last_name, first_name', 'asc'])
}, [h('span', 'Name/Surname'), h(UIcon, { name: getIcon('name') })]),
h(UInput, {
placeholder: 'Search...',
modelValue: filters.value[column.id] ?? '',
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
size: 'xs'
})
]),
cell: ({ row }) => `${row.original.first_name} ${row.original.last_name}`
},
{
accessorKey: 'email',
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
h('div', {
class: 'flex items-center gap-2 cursor-pointer',
onClick: () => (sortField.value = ['email', 'asc'])
}, [h('span', 'Email'), h(UIcon, { name: getIcon('email') })]),
h(UInput, {
placeholder: 'Search...',
modelValue: filters.value[column.id] ?? '',
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
size: 'xs'
})
]),
cell: ({ row }) => row.getValue('email')
},
{
accessorKey: 'count',
header: '',
cell: ({ row }) => {
return h(UButton, {
// onClick: () => {
// goToProduct(row.original.product_id, row.original.link_rewrite)
// },
class: 'cursor-pointer',
color: 'info',
variant: 'soft'
}, () => 'Manage')
},
},
{
accessorKey: 'profile',
header: '',
cell: ({ row }) => {
const userId = row.original.user_id
return h(UButton, {
color: 'info',
size: 'sm',
variant: 'soft',
onClick: () => {
router.push({
name: 'customer-management-profile',
params: { user_id: userId }
})
}
}, () => 'Go to profile')
}
}
]
</script>
<style scoped>
input::placeholder {
color: #9ca3af;
}
</style>

View File

@@ -0,0 +1,11 @@
<template>
<component :is="Management || 'div'">
<div>customer-management</div>
</component>
</template>
<script setup lang="ts">
import Management from '@/layouts/management.vue';
</script>

View File

@@ -52,7 +52,7 @@
</template>
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'
import { useCartStore } from '@/stores/customer/cart'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'

View File

@@ -104,7 +104,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAddressStore } from '@/stores/address'
import { useAddressStore } from '@/stores/customer/address'
import { useI18n } from 'vue-i18n'
import Default from '@/layouts/default.vue'
const addressStore = useAddressStore()

View File

@@ -152,8 +152,8 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useCartStore } from '@/stores/cart'
import { useAddressStore } from '@/stores/address'
import { useCartStore } from '@/stores/customer/cart'
import { useAddressStore } from '@/stores/customer/address'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import Default from '@/layouts/default.vue'

View File

@@ -1,75 +1,84 @@
<template>
<component :is="Default || 'div'">
<div class="">
<div class="flex md:flex-row flex-col justify-between gap-8 my-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-(--text-sky-light) dark:text-(--text-sky-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 class="">
<div class="flex md:flex-row flex-col justify-between gap-8 my-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 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 class="flex-1 flex flex-col gap-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ productData.name }}
</h1>
<UIcon name="material-symbols:favorite"
class="cursor-pointer text-2xl transition hover:scale-110"
:class="productData.is_favorite ? 'text-red-500' : 'text-gray-400'"
@click="toggleFavorite" />
</div>
<p class="text-gray-600 dark:text-gray-300">
{{ productData.description }}
</p>
<div class="text-3xl font-bold text-(--text-sky-light) dark:text-(--text-sky-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>
<div class="flex md:flex-row flex-col justify-between md:items-end items-start gap-5 md:gap-0 md:mb-8 mb-4">
<div class="flex flex-col gap-3">
<span class="text-sm text-(--text-sky-light) dark:text-(--text-sky-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 class="flex md:flex-row flex-col justify-between md:items-end items-start gap-5 md:gap-0 md:mb-8 mb-4">
<div class="flex flex-col gap-3">
<span class="text-sm text-(--text-sky-light) dark:text-(--text-sky-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>
<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>
<ProductCustomization />
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
<div class="mb-6 w-[100%] xl:w-[60%]">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-10 mb-8">
<UButton v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
'px-15 py-2 cursor-pointer sm:text-nowrap flex items-center! justify-center!',
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>
<ProductCustomization />
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
<div class="mb-6 w-[100%] xl:w-[60%]">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-10 mb-8">
<UButton v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
'px-15 py-2 cursor-pointer sm:text-nowrap flex items-center! justify-center!',
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>
</component>
</template>
@@ -78,6 +87,8 @@ import { ref, computed } from 'vue'
import ProductCustomization from './components/ProductCustomization.vue'
import ProductVariants from './components/ProductVariants.vue'
import Default from '@/layouts/default.vue'
import { useFetchJson } from '@/composable/useFetchJson'
import { useRoute } from 'vue-router'
interface Color {
id: string
name: string
@@ -97,6 +108,7 @@ interface ProductData {
howToUseText: string
productDetailsText: string
documentsText: string
is_favorite: boolean
}
const activeTab = ref('description')
@@ -157,6 +169,24 @@ const activeTabContent = computed(() => {
if (productData.colors.length > 0) {
selectedColor.value = productData.colors[0] as Color
}
const route = useRoute()
async function toggleFavorite() {
const url = `/api/v1/restricted/product/favorite/${route.params.product_id}`
try {
if (!productData.is_favorite) {
await useFetchJson(url, { method: 'POST' })
} else {
await useFetchJson(url, { method: 'DELETE' })
}
productData.is_favorite = !productData.is_favorite
} catch (e: unknown) {
console.error(e)
}
}
</script>
<style scoped>

View File

@@ -11,27 +11,30 @@
</template>
</UNavigationMenu> -->
<h1 class="text-2xl font-bold mb-6 text-gray-900 dark:text-white">Products</h1>
<div v-if="loading" class="text-center py-8">
<div v-if="customerProductStore.loading" class="text-center py-8">
<span class="text-gray-600 dark:text-gray-400">Loading products...</span>
</div>
<div v-else-if="error" class="mb-4 p-3 bg-red-100 text-red-700 rounded">
{{ error }}
<div v-else-if="customerProductStore.error" class="mb-4 p-3 bg-red-100 text-red-700 rounded">
{{ customerProductStore.error }}
</div>
<div v-else class="overflow-x-auto">
<div class="flex gap-2">
<CategoryMenu />
<UTable :data="productsList" :columns="columns" class="flex-1">
<UTable :data="customerProductStore.productsList" :columns="columns" class="flex-1">
<template #expanded="{ row }">
<UTable :data="productsList.slice(0, 3)" :columns="columnsChild" :ui="{
thead: 'hidden'
}" />
<UTable :data="customerProductStore.productsList.slice(0, 3)" :columns="columnsChild"
:ui="{
thead: 'hidden'
}" />
</template>
</UTable>
</div>
<div class="flex justify-center items-center py-8">
<UPagination v-model:page="page" :total="total" :page-size="perPage" />
<UPagination v-model:page="page" :total="customerProductStore.total"
:page-size="customerProductStore.perPage" />
</div>
<div v-if="productsList.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
<div v-if="customerProductStore.productsList.length === 0"
class="text-center py-8 text-gray-500 dark:text-gray-400">
No products found
</div>
</div>
@@ -42,20 +45,15 @@
<script setup lang="ts">
import { ref, watch, h, resolveComponent, computed } from 'vue'
import { useFetchJson } from '@/composable/useFetchJson'
import Default from '@/layouts/default.vue'
import { useRoute, useRouter } from 'vue-router'
import type { TableColumn } from '@nuxt/ui'
import CategoryMenu from '../inner/CategoryMenu.vue'
import { useCustomerProductStore } from '@/stores/customer/customer-product'
import type { Product } from '@/stores/customer/customer-product'
interface Product {
reference: number
product_id: number
name: string
image_link: string
link_rewrite: string
}
const customerProductStore = useCustomerProductStore()
const router = useRouter()
const route = useRoute()
@@ -101,18 +99,6 @@ const sortField = computed({
}
})
const perPage = ref(15)
const total = ref(0)
interface ApiResponse {
message: string
items: Product[]
count: number
}
const productsList = ref<Product[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const filters = computed<Record<string, string>>({
get: () => {
const q = { ...route.query }
@@ -157,36 +143,16 @@ const updateFilter = debounce((columnId: string, val: string) => {
filters.value = newFilters
}, 400)
async function fetchProductList() {
loading.value = true
error.value = null
const params = new URLSearchParams()
Object.entries(route.query).forEach(([key, value]) => {
if (value) params.append(key, String(value))
})
const url = `/api/v1/restricted/list/list-products?${params}`
try {
const response = await useFetchJson<ApiResponse>(url)
productsList.value = response.items || []
total.value = response.count || 0
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load products'
} finally {
loading.value = false
}
}
function goToProduct(productId: number) {
router.push({
name: 'product-detail',
params: { id: productId }
name: 'customer-product-details',
params: { product_id: productId }
})
}
const selectedCount = ref({
product_id: null,
count: 0
@@ -205,7 +171,7 @@ const UInput = resolveComponent('UInput')
const UButton = resolveComponent('UButton')
const UIcon = resolveComponent('UIcon')
const columns: TableColumn<Payment>[] = [
const columns: TableColumn<Product>[] = [
{
id: 'expand',
cell: ({ row }) =>
@@ -351,6 +317,35 @@ const columns: TableColumn<Payment>[] = [
variant: 'solid'
}, 'Add to cart')
},
},
{
accessorKey: 'count',
header: '',
cell: ({ row }) => {
return h(UButton, {
onClick: () => {
goToProduct(row.original.product_id)
},
class: 'cursor-pointer',
color: 'info',
variant: 'soft'
}, () => 'Show product')
},
},
{
accessorKey: 'counta',
header: '',
cell: ({ row }) => {
return h(UIcon, {
onClick: () => customerProductStore.toggleFavorite(row.original),
class: [
'cursor-pointer text-[20px] transition-transform duration-200 hover:scale-125',
row.original.is_favorite ? 'text-red-500' : 'text-blue-500'
],
name: 'material-symbols:favorite',
variant: 'soft',
})
}
}
]
@@ -417,13 +412,27 @@ const columnsChild: TableColumn<Payment>[] = [
variant: 'solid'
}, 'Add to cart')
},
},
{
accessorKey: 'count',
header: '',
cell: ({ row }) => {
return h(UButton, {
onClick: () => {
goToProduct(row.original.product_id)
},
class: 'cursor-pointer',
color: 'info',
variant: 'soft'
}, () => 'Show product')
},
}
]
watch(
() => route.query,
() => {
fetchProductList()
customerProductStore.fetchProductList()
},
{ immediate: true }
)

View File

@@ -104,7 +104,7 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useCustomerStore } from '@/stores/customer'
import { useAddressStore } from '@/stores/address'
import { useAddressStore } from '@/stores/customer/address'
import { useI18n } from 'vue-i18n'
import Default from '@/layouts/default.vue'
const router = useRouter()

View File

@@ -116,9 +116,9 @@
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useCustomerStore } from '@/stores/customer'
import { useAddressStore } from '@/stores/address'
import { useAddressStore } from '@/stores/customer/address'
import { useI18n } from 'vue-i18n'
import { useCartStore } from '@/stores/cart'
import { useCartStore } from '@/stores/customer/cart'
import Default from '@/layouts/default.vue'
const router = useRouter()
const customerStore = useCustomerStore()

View File

@@ -0,0 +1,161 @@
<template>
<component :is="Default || 'div'">
<div class="p-4">
<div v-if="loading" class="flex justify-center py-8">
<ULoader />
</div>
<div v-else-if="error" class="text-red-500">
{{ error }}
</div>
<UTree v-if="showTree" :items="treeItems" v-model:expanded="expandedFolders" :key="treeKey" @toggle="onToggle" :get-key="item => item.value">
<template #item-wrapper="{ item }">
<div class="flex items-start cursor-pointer" @click.stop="!item.isFolder">
<div class="flex items-center gap-1">
<UIcon :name="item.icon" :size="30" />
<div class="flex gap-1 items-center">
<span class="text-[15px] font-medium">
{{ item.label }}
</span>
<UButton v-if="!item.isFolder && item.fileName" size="xxs" color="neutral"
variant="outline" icon="i-lucide-download"
@click.stop="downloadFile(item.path, item.fileName)" :ui="{ base: 'ring-0!' }" />
</div>
</div>
</div>
</template>
</UTree>
</div>
</component>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useFetchJson } from '@/composable/useFetchJson'
import Default from '@/layouts/default.vue'
interface FileItemRaw {
Name: string
IsFolder: boolean
}
interface FileItem {
name: string
type: 'file' | 'folder'
}
interface TreeItem {
label: string
icon: string
children?: TreeItem[]
isFolder: boolean
path: string
value: string
fileName?: string
}
const props = defineProps<{ initialPath?: string }>()
const currentPath = ref(props.initialPath || '')
const allData = ref<Record<string, FileItem[]>>({})
const expandedFolders = ref<string[]>([])
const treeKey = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const showTree = computed(() => !error.value)
async function fetchFolderContents(path: string): Promise<FileItem[]> {
const url = `/api/v1/restricted/storage/list-content/${path}`
const data = await useFetchJson<FileItemRaw[]>(url)
return (data.items || []).map(i => ({
name: i.Name,
type: i.IsFolder ? 'folder' : 'file'
}))
}
async function loadFolder(path: string) {
if (allData.value[path]) return
try {
const items = await fetchFolderContents(path)
allData.value[path] = items
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load folder contents'
}
}
function buildTree(path: string): TreeItem[] {
const items = allData.value[path] || []
return items.map(item => {
const itemPath = path ? `${path}/${item.name}` : item.name
const isFolder = item.type === 'folder'
const isExpanded = expandedFolders.value.includes(itemPath)
const isLoaded = !!allData.value[itemPath]
return {
label: item.name,
icon: isFolder ? 'fxemoji:folder' : 'flat-color-icons:file',
isFolder,
path: isFolder ? itemPath : path,
value: itemPath,
fileName: isFolder ? undefined : item.name,
children: isFolder && isExpanded && isLoaded
? buildTree(itemPath)
: []
}
})
}
const treeItems = computed(() => buildTree(currentPath.value))
async function toggleFolder(item: TreeItem) {
if (!item.isFolder) return
const isOpen = expandedFolders.value.includes(item.value)
if (!isOpen) {
await loadFolder(item.value)
treeKey.value++
} else {
treeKey.value++
}
}
function onToggle(_event: unknown, item: TreeItem) {
console.log('Toggle:', item)
if (item.isFolder) {
toggleFolder(item)
}
}
async function downloadFile(path: string, fileName: string) {
try {
const response = await fetch(`/api/v1/restricted/storage/download-file/${path}/${fileName}`)
if (!response.ok) throw new Error('Download failed')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
} catch (e) {
console.error(e)
alert('Download failed')
}
}
loadFolder(currentPath.value)
</script>

View File

@@ -1,5 +1,7 @@
<template>
<UNavigationMenu orientation="vertical" type="single" :items="items" class="data-[orientation=vertical]:w-72" />
<UNavigationMenu orientation="vertical" type="single" :items="items" class="data-[orientation=vertical]:w-72" :ui="{
root:''
}"/>
</template>
<script setup lang="ts">

View File

@@ -32,6 +32,6 @@
</template>
<script setup lang="ts">
import { useThemeStore } from '@/stores/theme'
import { useThemeStore } from '@/stores/admin/theme'
const themeStorage = useThemeStore()
</script>

View File

@@ -29,7 +29,7 @@ export async function useFetchJson<T = unknown>(url: string, opt?: RequestInit):
// Handle 401 — access token expired; try to refresh via the HTTPOnly refresh_token cookie
if (res.status === 401) {
const { useAuthStore } = await import('@/stores/auth')
const { useAuthStore } = await import('../stores/customer/auth')
const authStore = useAuthStore()
const refreshed = await authStore.refreshAccessToken()

View File

@@ -23,18 +23,23 @@
<template #footer>
<UDropdownMenu :items="userItems" :content="{ align: 'center', collisionPadding: 12 }"
:ui="{ content: 'w-(--reka-dropdown-menu-trigger-width) min-w-48' }">
<UButton v-bind="user" :label="user?.name" trailing-icon="i-lucide-chevrons-up-down" color="neutral"
variant="ghost" square class="w-full data-[state=open]:bg-elevated overflow-hidden" :ui="{
<UButton v-bind="userStore.user" :label="userStore.user?.email" trailing-icon="i-lucide-chevrons-up-down"
color="neutral" variant="ghost" square class="w-full data-[state=open]:bg-elevated overflow-hidden" :ui="{
trailingIcon: 'text-dimmed ms-auto'
}" />
</UDropdownMenu>
<!-- first_name: '', last_name: '' -->
</template>
</USidebar>
<div class="flex-1 flex flex-col">
<div class="flex h-(--ui-header-height) shrink-0 items-center justify-between px-4 border-b border-default">
<UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar"
@click="open = !open" />
<div class="flex items-center gap-2">
<UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar"
@click="open = !open" />
<span class="text-[20px] font-medium">{{ pageTitle }}</span>
</div>
<div class="hidden md:flex items-center gap-12">
<div class="flex items-center gap-2">
<CountryCurrencySwitch />
@@ -43,14 +48,15 @@
<div class="flex items-center gap-2">
<ThemeSwitch />
<button v-if="authStore.isAuthenticated" @click="authStore.logout()"
class="px-3 py-1.5 text-sm font-medium text-black dark:text-white hover:text-black dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors border border-(--border-light) dark:border-(--border-dark) whitespace-nowrap">
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium text-black dark:text-white hover:text-black dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors border border-(--border-light) dark:border-(--border-dark) whitespace-nowrap">
{{ $t('general.logout') }}
<UIcon name="ic:baseline-logout" class="text-red-700 dark:text-red-500"/>
</button>
</div>
</div>
</div>
<div class="flex-1 p-4 bg-slate-50">
<div class="flex-1 p-4 bg-slate-50 dark:bg-(--main-dark)">
<slot />
</div>
</div>
@@ -62,10 +68,16 @@ import { ref, computed, onMounted } from 'vue'
import { useColorMode } from '@vueuse/core'
import type { DropdownMenuItem, NavigationMenuItem } from '@nuxt/ui'
import { defineShortcuts, extractShortcuts } from '@nuxt/ui/runtime/composables/defineShortcuts.js'
import { LabelTrans, TopMenuItem } from '@/types'
import { useAuthStore } from '../stores/customer/auth'
const authStore = useAuthStore()
const userStore = useUserStore()
const route = useRoute()
const pageTitle = computed(() => route.meta.name ?? 'Default Page')
await userStore.getUser()
const open = ref(true)
const authStore = useAuthStore()
const colorMode = useColorMode()
const teams = ref([
@@ -152,13 +164,14 @@ function getItems(state: 'collapsed' | 'expanded') {
}
//
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { currentLang } from '@/router/langs'
import { useFetchJson } from '@/composable/useFetchJson'
import { useAuthStore } from '@/stores/auth'
import CountryCurrencySwitch from '@/components/inner/CountryCurrencySwitch.vue'
import LangSwitch from '@/components/inner/LangSwitch.vue'
import ThemeSwitch from '@/components/inner/ThemeSwitch.vue'
import type { LabelTrans, TopMenuItem } from '@/types'
import { useUserStore } from '@/stores/user'
const router = useRouter()
@@ -168,7 +181,7 @@ const menu = ref<TopMenuItem[] | null>(null)
async function getTopMenu() {
try {
const { items } = await useFetchJson<TopMenuItem[]>('/api/v1/restricted/menu/get-top-menu')
menu.value = items
} catch (err) {
console.log(err)
@@ -217,13 +230,6 @@ function transformMenu(
})
}
const user = ref({
name: 'Benjamin Canac',
avatar: {
src: 'https://github.com/benjamincanac.png',
alt: 'Benjamin Canac'
}
})
const userItems = computed<DropdownMenuItem[][]>(() => [
[

View File

@@ -0,0 +1,311 @@
<template>
<div class="flex flex-1 overflow-x-hidden h-svh">
<USidebar v-model:open="open" collapsible="icon" rail :ui="{
container: 'h-full z-80',
inner: 'bg-elevated/25 divide-transparent',
body: 'py-0'
}">
<template #header>
<UDropdownMenu :items="teamsItems" :content="{ align: 'start', collisionPadding: 12 }"
:ui="{ content: 'w-(--reka-dropdown-menu-trigger-width) min-w-48' }">
<UButton v-bind="selectedTeam" trailing-icon="i-lucide-chevrons-up-down" color="neutral"
variant="ghost" square class="w-full data-[state=open]:bg-elevated overflow-hidden" :ui="{
trailingIcon: 'text-dimmed ms-auto'
}" />
</UDropdownMenu>
</template>
<template #default="{ state }">
<UNavigationMenu :key="state" :items="menuItems" orientation="vertical"
:ui="{ link: 'p-1.5 overflow-hidden' }" />
</template>
<template #footer>
<UDropdownMenu :items="userItems" :content="{ align: 'center', collisionPadding: 12 }"
:ui="{ content: 'w-(--reka-dropdown-menu-trigger-width) min-w-48' }">
<UButton v-bind="userStore.user" :label="userStore.user?.email"
trailing-icon="i-lucide-chevrons-up-down" color="neutral" variant="ghost" square
class="w-full data-[state=open]:bg-elevated overflow-hidden" :ui="{
trailingIcon: 'text-dimmed ms-auto'
}" />
</UDropdownMenu>
<!-- first_name: '', last_name: '' -->
</template>
</USidebar>
<div class="flex-1 flex flex-col">
<div class="flex h-(--ui-header-height) shrink-0 items-center justify-between px-4 border-b border-default">
<div class="flex items-center gap-2">
<UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar"
@click="open = !open" />
<p class="font-bold text-xl">Customer-Management: <span class="text-[20px] font-medium">{{ pageTitle
}}</span></p>
</div>
<div class="hidden md:flex items-center gap-12">
<div class="flex items-center gap-2">
<CountryCurrencySwitch />
<LangSwitch />
</div>
<div class="flex items-center gap-2">
<ThemeSwitch />
<button v-if="authStore.isAuthenticated" @click="authStore.logout()"
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium text-black dark:text-white hover:text-black dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors border border-(--border-light) dark:border-(--border-dark) whitespace-nowrap">
{{ $t('general.logout') }}
<UIcon name="ic:baseline-logout" class="text-red-700 dark:text-red-500" />
</button>
</div>
</div>
</div>
<div class="flex-1 p-4 bg-slate-50 dark:bg-(--main-dark)">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useColorMode } from '@vueuse/core'
import type { DropdownMenuItem, NavigationMenuItem } from '@nuxt/ui'
import { defineShortcuts, extractShortcuts } from '@nuxt/ui/runtime/composables/defineShortcuts.js'
import { useAuthStore } from '../stores/customer/auth'
import { useRoute } from 'vue-router'
const route = useRoute()
const pageTitle = computed(() => route.meta.name ?? 'Default Page')
const authStore = useAuthStore()
const userStore = useUserStore()
const open = ref(true)
const colorMode = useColorMode()
const teams = ref([
{
label: 'Nuxt',
avatar: {
src: 'https://github.com/nuxt.png',
alt: 'Nuxt'
}
},
{
label: 'Vue',
avatar: {
src: 'https://github.com/vuejs.png',
alt: 'Vue'
}
},
{
label: 'UnJS',
avatar: {
src: 'https://github.com/unjs.png',
alt: 'UnJS'
}
}
])
const selectedTeam = ref(teams.value[0])
const teamsItems = computed<DropdownMenuItem[][]>(() => {
return [
teams.value.map((team, index) => ({
...team,
kbds: ['meta', String(index + 1)],
onSelect() {
selectedTeam.value = team
}
})),
[
{
label: 'Create team',
icon: 'i-lucide-circle-plus'
}
]
]
})
function getItems(state: 'collapsed' | 'expanded') {
return [
{
label: 'Inbox',
icon: 'i-lucide-inbox',
badge: '4'
},
{
label: 'Issues',
icon: 'i-lucide-square-dot'
},
{
label: 'Activity',
icon: 'i-lucide-square-activity'
},
{
label: 'Settings',
icon: 'i-lucide-settings',
defaultOpen: true,
children:
state === 'expanded'
? [
{
label: 'General',
icon: 'i-lucide-house'
},
{
label: 'Team',
icon: 'i-lucide-users'
},
{
label: 'Billing',
icon: 'i-lucide-credit-card'
}
]
: []
}
] satisfies NavigationMenuItem[]
}
//
import { useRouter } from 'vue-router'
import { currentLang } from '@/router/langs'
import { useFetchJson } from '@/composable/useFetchJson'
import CountryCurrencySwitch from '@/components/inner/CountryCurrencySwitch.vue'
import LangSwitch from '@/components/inner/LangSwitch.vue'
import ThemeSwitch from '@/components/inner/ThemeSwitch.vue'
import type { LabelTrans, TopMenuItem } from '@/types'
import { useUserStore } from '@/stores/user'
import { watch } from 'vue'
const router = useRouter()
const menu = ref<TopMenuItem[] | null>(null)
const Id =Number(route.params.user_id)
async function cmGetTopMenu() {
try {
const { items } = await useFetchJson<TopMenuItem[]>(`/api/v1/restricted/menu/get-top-menu?target_user_id=${Id}`)
menu.value = items
} catch (err) {
console.log(err)
}
}
console.log(route)
watch(
() => route.params.user_id,
() => {
if (route.params.user_id) {
cmGetTopMenu()
}
},
{ immediate: true }
)
const menuItems = computed(() => {
if (!menu.value?.length) return []
return transformMenu(
menu.value || [],
currentLang.value?.iso_code
)
})
function transformMenu(
items: TopMenuItem[],
locale: string | undefined
): NavigationMenuItem[] {
return items.map((item) => {
const route: NavigationMenuItem = {
icon: item.label.icon || 'i-lucide-house',
label:
item.label.trans?.[locale as keyof LabelTrans]?.label ||
item.label.trans?.en?.label ||
'—',
children: item.children
? transformMenu(item.children, locale)
: undefined,
onSelect: () => {
router.push({
name: item.params.route.name,
params: {
...(item.params.route.params || {}),
locale: currentLang.value?.iso_code
}
})
}
}
return route
})
}
const userItems = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Profile',
icon: 'i-lucide-user'
},
{
label: 'Billing',
icon: 'i-lucide-credit-card'
},
{
label: 'Settings',
icon: 'i-lucide-settings',
to: '/settings'
}
],
[
{
label: 'Appearance',
icon: 'i-lucide-sun-moon',
children: [
{
label: 'Light',
icon: 'i-lucide-sun',
type: 'checkbox',
checked: colorMode.value === 'light',
onUpdateChecked(checked: boolean) {
if (checked) {
colorMode.preference = 'light'
}
},
onSelect(e: Event) {
e.preventDefault()
}
},
{
label: 'Dark',
icon: 'i-lucide-moon',
type: 'checkbox',
checked: colorMode.value === 'dark',
onUpdateChecked(checked: boolean) {
if (checked) {
colorMode.preference = 'dark'
}
},
onSelect(e: Event) {
e.preventDefault()
}
}
]
}
],
[
{
label: 'GitHub',
icon: 'i-simple-icons-github',
to: 'https://github.com/nuxt/ui',
target: '_blank'
},
{
label: 'Log out',
icon: 'i-lucide-log-out'
}
]
])
defineShortcuts(extractShortcuts(teamsItems.value))
</script>

View File

@@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import { currentLang, langs, switchLocalization } from './langs'
import { getSettings } from './settings'
import { useAuthStore } from '@/stores/auth'
import { useAuthStore } from '@/stores/customer/auth'
import { getRoutes } from './menu'
function isAuthenticated(): boolean {

View File

@@ -1,6 +1,5 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Address } from './address'
export interface CustomerData {
companyName: string

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useFetchJson } from '@/composable/useFetchJson'
import { useUserStore } from '../user'
export interface User {
id: string
@@ -41,6 +42,8 @@ export const useAuthStore = defineStore('auth', () => {
_isAuthenticated.value = readIsAuthenticatedCookie()
}
// const auth = useAuthStore()
// const userStore = useUserStore()
async function login(email: string, password: string) {
loading.value = true
error.value = null
@@ -60,6 +63,7 @@ export const useAuthStore = defineStore('auth', () => {
user.value = response.user
_syncAuthState()
// await userStore.getUser()
return true
} catch (e: any) {
@@ -99,7 +103,7 @@ export const useAuthStore = defineStore('auth', () => {
error.value = null
try {
const body: any = { first_name, last_name, email, password, confirm_password, lang: lang || 'en' }
// Add company information if provided
if (company_name) body.company_name = company_name
if (company_email) body.company_email = company_email

View File

@@ -0,0 +1,86 @@
import { useFetchJson } from '@/composable/useFetchJson'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
export interface Product {
id: number
image: string
name: string
productDetails?: string
product_id: number
is_favorite?: boolean
}
export interface ProductResponse {
items: Product[]
items_count: number
}
export interface ApiResponse {
message: string
items: Product[]
count: number
}
export const useCustomerProductStore = defineStore('customer-product', () => {
const loading = ref(true)
const error = ref<string | null>(null)
const route = useRoute()
const productsList = ref<Product[]>([])
const total = ref(0)
const perPage = ref(15)
async function fetchProductList() {
loading.value = true
error.value = null
const params = new URLSearchParams()
Object.entries(route.query).forEach(([key, value]) => {
if (value) params.append(key, String(value))
})
const url = `/api/v1/restricted/product/list?${params}`
try {
const response = await useFetchJson<ApiResponse>(url)
productsList.value = response.items || []
total.value = response.count || 0
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load products'
} finally {
loading.value = false
}
}
async function toggleFavorite(product: Product) {
const productId = product.product_id
const isFavorite = product.is_favorite
const url = `/api/v1/restricted/product/favorite/${productId}`
try {
if (!isFavorite) {
await useFetchJson(url, { method: 'POST' })
} else {
await useFetchJson(url, { method: 'DELETE' })
}
product.is_favorite = !isFavorite
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to update favorite'
}
}
return {
fetchProductList,
toggleFavorite,
productsList,
total,
loading,
error,
perPage
}
})

32
bo/src/stores/user.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { User } from '@/types/user'
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useFetchJson } from '@/composable/useFetchJson'
export const useUserStore = defineStore('user', () => {
const error = ref<string | null>(null)
const user = ref<User | null>(null)
async function getUser() {
error.value = null
try {
const data = await useFetchJson<User>(`/api/v1/restricted/customer`)
console.log('getUser API response:', data)
const response: User = (data as any).items ?? data
console.log('User response:', response)
user.value = response
return response
} catch (err: any) {
error.value = err?.message ?? 'Unknown error'
return null
}
}
return {
error,
user,
getUser
}
})

23
bo/src/types/user.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
export interface User {
id: string
email: string
name: string
first_name?: string
last_name?: string
company_name?: string
company_email?: string
company_address?: Address
billing_address?: Address
regon?: string
nip?: string
vat?: string
}
interface Customer {
user_id: number
email: string
first_name: string
last_name: string
}

View File

@@ -20,7 +20,7 @@
<script setup lang="ts">
// import { useRoute } from 'vue-router';
import Default from '@/layouts/default.vue';
import { useCategoryStore } from '@/stores/category';
import { useCategoryStore } from '@/stores/admin/category';
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
// const route = useRoute()

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useAuthStore } from '@/stores/customer/auth'
import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui'
import { i18n } from '@/plugins/02_i18n'

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useAuthStore } from '@/stores/customer/auth'
import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui'
import { i18n } from '@/plugins/02_i18n'

View File

@@ -117,7 +117,7 @@
<script setup lang="ts">
import { ref, computed, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useAuthStore } from '@/stores/customer/auth'
import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui'
import { useI18n } from 'vue-i18n'

View File

@@ -11,7 +11,7 @@ import {
LinearScale,
} from 'chart.js'
import { getYears, getQuarters, getIssues, type QuarterData, type IssueTimeSummary } from '@/composable/useRepoApi'
import { useAuthStore } from '@/stores/auth'
import { useAuthStore } from '@/stores/customer/auth'
import { i18n } from '@/plugins/02_i18n'
import type { TableColumn } from '@nuxt/ui'
import { useI18n } from 'vue-i18n'

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useAuthStore } from '@/stores/customer/auth'
import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui'
import { i18n } from '@/plugins/02_i18n'

View File

@@ -0,0 +1,12 @@
<template>
<Default>
<div class="h-full">
<StorageFileBrowser initial-path="dest/src" />
</div>
</Default>
</template>
<script setup lang="ts">
import Default from '@/layouts/default.vue'
import StorageFileBrowser from '@/components/customer/StorageFileBrowser.vue'
</script>

View File

@@ -1,7 +1,7 @@
info:
name: Change Locales
type: http
seq: 3
seq: 5
http:
method: POST

View File

@@ -1,7 +1,7 @@
info:
name: Delete Index - MeiliSearch
type: http
seq: 5
seq: 7
http:
method: DELETE

View File

@@ -1,7 +1,7 @@
info:
name: Search Index Settings
type: http
seq: 4
seq: 6
http:
method: POST

View File

@@ -1,7 +1,7 @@
info:
name: Search Items
type: http
seq: 2
seq: 4
http:
method: POST

View File

@@ -1,7 +1,7 @@
info:
name: auth
type: folder
seq: 6
seq: 2
request:
auth: inherit

View File

@@ -11,7 +11,7 @@ http:
data: |-
{
"b2b_id_currency" : 1,
"conversion_rate": 4.2
"conversion_rate": 3
}
auth: inherit

View File

@@ -11,7 +11,7 @@ http:
runtime:
variables:
- name: id
value: "1"
value: "2"
settings:
encodeUrl: true

View File

@@ -1,7 +1,7 @@
info:
name: currency
type: folder
seq: 8
seq: 9
request:
auth: inherit

View File

@@ -5,10 +5,10 @@ info:
http:
method: GET
url: "{{bas_url}}/restricted/customer?id=1"
url: "{{bas_url}}/restricted/customer?id=2"
params:
- name: id
value: "1"
value: "2"
type: query
auth: inherit

View File

@@ -5,10 +5,10 @@ info:
http:
method: GET
url: "{{bas_url}}/restricted/customer/list?search="
url: "{{bas_url}}/restricted/customer/list?search=marek"
params:
- name: search
value: ""
value: marek
type: query
auth: inherit

View File

@@ -1,7 +1,7 @@
info:
name: customer
type: folder
seq: 9
seq: 10
request:
auth: inherit

View File

@@ -5,7 +5,7 @@ info:
http:
method: GET
url: "{{bas_url}}/restricted/product/200/1/5"
url: "{{bas_url}}/restricted/product/51/1/7"
auth: inherit
settings:

View File

@@ -0,0 +1,22 @@
info:
name: Product Variants List
type: http
seq: 3
http:
method: GET
url: "{{bas_url}}/restricted/product/list-variants/{{product_id}}"
body:
type: json
data: ""
runtime:
variables:
- name: product_id
value: "51"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -5,7 +5,7 @@ info:
http:
method: GET
url: "{{bas_url}}/restricted/product/list?p=1&elems=30&sort=product_id,asc&category_id_in=243&reference=~62"
url: "{{bas_url}}/restricted/product/list?p=1&elems=30&sort=product_id,asc&reference=~NC100"
params:
- name: p
value: "1"
@@ -19,8 +19,9 @@ http:
- name: category_id_in
value: "243"
type: query
disabled: true
- name: reference
value: ~62
value: ~NC100
type: query
body:
type: json

View File

@@ -1,7 +1,7 @@
info:
name: product
type: folder
seq: 7
seq: 8
request:
auth: inherit

View File

@@ -0,0 +1,20 @@
info:
name: Activate
type: http
seq: 5
http:
method: PATCH
url: "{{bas_url}}/restricted/specific-price/{{id}}/activate"
auth: inherit
runtime:
variables:
- name: id
value: "1"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,27 @@
info:
name: Create
type: http
seq: 3
http:
method: POST
url: "{{bas_url}}/restricted/specific-price"
body:
type: json
data: |-
{
"name": "Summer Sale 3",
"scope": "shop",
"reduction_type": "amount",
"price": 69,
"from_quantity": 1,
"is_active": true,
"currency_id": 2
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,20 @@
info:
name: Deactivate
type: http
seq: 6
http:
method: PATCH
url: "{{bas_url}}/restricted/specific-price/{{id}}/deactivate"
auth: inherit
runtime:
variables:
- name: id
value: "1"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,20 @@
info:
name: Delete
type: http
seq: 7
http:
method: DELETE
url: "{{bas_url}}/restricted/price-reductions/{{id}}"
auth: inherit
runtime:
variables:
- name: id
value: "1"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,20 @@
info:
name: Get
type: http
seq: 2
http:
method: GET
url: "{{bas_url}}/restricted/specific-price/{{id}}"
auth: inherit
runtime:
variables:
- name: id
value: "1"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,15 @@
info:
name: List
type: http
seq: 1
http:
method: GET
url: "{{bas_url}}/restricted/specific-price"
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,38 @@
info:
name: Update
type: http
seq: 4
http:
method: PUT
url: "{{bas_url}}/restricted/specific-price/{{id}}"
body:
type: json
data: |-
{
"name": "Summer Sale Updated",
"reduction_type": "amount",
"percentage_reduction": 50.0,
"price": 69,
"currency_id": 1,
"scope": "shop",
"is_active": true,
"from_quantity":1
// "product_ids": [51,53],
// "category_ids": [1],
// "product_attribute_ids": [1114],
// "country_ids": [1],
// "customer_ids": [2,1]
}
auth: inherit
runtime:
variables:
- name: id
value: "3"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,73 @@
info:
name: specific_price
type: folder
seq: 3
docs:
content: |
# Specific Price API
Endpoints for managing specific price rules (price reductions).
## Scopes
Specific prices can be **global** or **scoped**:
- **Global**: If all scope arrays (`product_ids`, `category_ids`, `product_attribute_ids`, `country_ids`, `customer_ids`) are empty, the price reduction applies to everything.
- **Scoped**: If ANY scope array has values, the price reduction applies only when ANY condition matches (UNION logic).
### Scope Fields
| Field | Type | Description |
|-------|------|-------------|
| `product_ids` | uint[] | Specific products |
| `category_ids` | uint[] | Products in categories |
| `product_attribute_ids` | uint[] | Product variants (e.g., size, color) |
| `country_ids` | uint[] | Customers in countries |
| `customer_ids` | uint[] | Specific customers |
### Examples
**Global** (applies to all products):
```json
{
"name": "Global Sale",
"reduction_type": "percentage",
"percentage_reduction": 10,
"from_quantity": 1
}
```
**Scoped to specific products**:
```json
{
"name": "Product Sale",
"reduction_type": "percentage",
"percentage_reduction": 20,
"product_ids": [1, 2, 3]
}
```
**Scoped to category + country**:
```json
{
"name": "Category Country Sale",
"reduction_type": "amount",
"price": 9.99,
"category_ids": [5],
"country_ids": [1]
}
```
## Reduction Types
- `percentage`: Requires `percentage_reduction` (e.g., 10.5 = 10.5% off)
- `amount`: Requires `price` (fixed price after reduction)
## Validation
- `reduction_type` is required and must be "percentage" or "amount"
- If `reduction_type` is "percentage", then `percentage_reduction` is required
- If `reduction_type` is "amount", then `price` is required
type: text/markdown

View File

@@ -238,7 +238,6 @@ CREATE TABLE b2b_specific_price (
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL,
scope ENUM('shop', 'category', 'product') NOT NULL,
valid_from DATETIME NULL,
valid_till DATETIME NULL,
has_expiration_date BOOLEAN DEFAULT FALSE,
@@ -249,11 +248,9 @@ CREATE TABLE b2b_specific_price (
from_quantity INT UNSIGNED DEFAULT 1,
is_active BOOLEAN DEFAULT TRUE
) ENGINE = InnoDB;
CREATE INDEX idx_b2b_scope ON b2b_specific_price(scope);
CREATE INDEX idx_b2b_active_dates ON b2b_specific_price(is_active, valid_from, valid_till);
CREATE INDEX idx_b2b_lookup
ON b2b_specific_price (
scope,
is_active,
from_quantity
);
@@ -307,11 +304,11 @@ ON b2b_specific_price_category (id_category);
CREATE INDEX idx_b2b_product_attribute_rel
ON b2b_specific_price_product_attribute (id_product_attribute);
CREATE INDEX idx_bsp_customer
ON b2b_specific_price_customer (b2b_specific_price_id, b2b_id_customer);
CREATE INDEX idx_bsp_customer_rel
ON b2b_specific_price_customer (b2b_id_customer);
CREATE INDEX idx_bsp_country
ON b2b_specific_price_country (b2b_specific_price_id, b2b_id_country);
CREATE INDEX idx_bsp_country_rel
ON b2b_specific_price_country (b2b_id_country);
DELIMITER //

View File

@@ -33,13 +33,16 @@ INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('1', 'user.read.any');
INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('2', 'user.write.any');
INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('3', 'user.delete.any');
INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('4', 'currency.write');
INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('5', 'specific_price.manage');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '1');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '2');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '3');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '4');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '5');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '1');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '2');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '3');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '4');
INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '5');
-- +goose Down

View File

@@ -1,386 +1,410 @@
-- +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 b2b_id_country INT UNSIGNED,
IN p_quantity INT UNSIGNED
)
BEGIN
DECLARE v_tax_rate DECIMAL(10, 4) DEFAULT 0;
DECLARE p_id_currency 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
LEFT JOIN b2b_countries ON b2b_countries.id = b2b_id_country
WHERE
tr.id_tax_rules_group = (
SELECT
ps.id_tax_rules_group
FROM
ps_product_shop ps
WHERE
ps.id_product = p_id_product
DELIMITER //
DROP FUNCTION IF EXISTS fn_product_price //
CREATE FUNCTION fn_product_price(
p_id_product INT UNSIGNED,
p_id_shop INT UNSIGNED,
p_id_customer INT UNSIGNED,
p_id_country INT UNSIGNED,
p_quantity INT UNSIGNED,
p_id_product_attribute INT UNSIGNED
)
RETURNS JSON
DETERMINISTIC
READS SQL DATA
BEGIN
DECLARE v_tax_rate DECIMAL(10,4) DEFAULT 0;
DECLARE v_base_raw DECIMAL(20,6);
DECLARE v_base DECIMAL(20,6);
DECLARE v_excl DECIMAL(20,6);
DECLARE v_incl DECIMAL(20,6);
DECLARE v_reduction_type VARCHAR(20);
DECLARE v_percentage DECIMAL(10,4);
DECLARE v_fixed_price DECIMAL(20,6);
DECLARE v_specific_currency_id BIGINT;
DECLARE v_has_specific INT DEFAULT 0;
-- currency
DECLARE v_target_currency BIGINT;
DECLARE v_target_rate DECIMAL(13,6) DEFAULT 1;
DECLARE v_specific_rate DECIMAL(13,6) DEFAULT 1;
SET p_id_product_attribute = NULLIF(p_id_product_attribute, 0);
-- ================= TAX =================
SELECT COALESCE(t.rate, 0)
INTO v_tax_rate
FROM ps_tax_rule tr
JOIN ps_tax t ON t.id_tax = tr.id_tax
LEFT JOIN b2b_countries c ON c.id = p_id_country
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
LIMIT 1
)
AND tr.id_country = b2b_countries.ps_id_country
ORDER BY
tr.id_state DESC,
tr.zipcode_from != '' DESC,
tr.id_tax_rule DESC
LIMIT
1;
SELECT
b2b_currencies.ps_id_currency INTO p_id_currency
FROM
b2b_currencies
LEFT JOIN b2b_countries ON b2b_countries.b2b_id_currency = b2b_currencies.id
WHERE
b2b_countries.id = b2b_id_country
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 * r.conversion_rate, p.price * r.conversion_rate),
AND tr.id_country = c.ps_id_country
LIMIT 1;
'final_tax_excl',
(
CASE
WHEN bsp.id IS NOT NULL THEN
CASE
/* FIXED PRICE */
WHEN bsp.reduction_type = 'amount' THEN
(
CASE
WHEN bsp.b2b_id_currency IS NULL THEN bsp.price
ELSE bsp.price * br_bsp.conversion_rate
END
)
-- ================= TARGET CURRENCY =================
SELECT c.b2b_id_currency
INTO v_target_currency
FROM b2b_countries c
WHERE c.id = p_id_country
LIMIT 1;
/* PERCENTAGE */
WHEN bsp.reduction_type = 'percentage' THEN
COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate)
* (1 - bsp.percentage_reduction / 100)
-- latest target rate
SELECT r.conversion_rate
INTO v_target_rate
FROM b2b_currency_rates r
WHERE r.b2b_id_currency = v_target_currency
ORDER BY r.created_at DESC
LIMIT 1;
ELSE
COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate)
END
-- ================= BASE PRICE (RAW) =================
SELECT
COALESCE(ps.price, p.price) + COALESCE(pas.price, 0)
INTO v_base_raw
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_attribute_shop pas
ON pas.id_product_attribute = p_id_product_attribute
AND pas.id_shop = p_id_shop
WHERE p.id_product = p_id_product;
ELSE
COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate)
END
),
-- convert base to target currency
SET v_base = v_base_raw * v_target_rate;
'final_tax_incl',
(
(
CASE
WHEN bsp.id IS NOT NULL THEN
CASE
WHEN bsp.reduction_type = 'amount' THEN
(
CASE
WHEN bsp.b2b_id_currency IS NULL THEN bsp.price
ELSE bsp.price * br_bsp.conversion_rate
END
)
-- ================= RULE SELECTION =================
SELECT
1,
bsp.reduction_type,
bsp.percentage_reduction,
bsp.price,
bsp.b2b_id_currency
WHEN bsp.reduction_type = 'percentage' THEN
COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate)
* (1 - bsp.percentage_reduction / 100)
INTO
v_has_specific,
v_reduction_type,
v_percentage,
v_fixed_price,
v_specific_currency_id
ELSE
COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate)
END
ELSE
COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate)
END
) * (1 + v_tax_rate / 100)
)
),
/* ================= META ================= */
'active',
COALESCE(ps.active, p.active),
'visibility',
COALESCE(ps.visibility, p.visibility),
'manufacturer',
m.name,
'category',
cl.name,
/* ================= FAVORITE ================= */
'is_favorite',
EXISTS(
SELECT 1 FROM b2b_favorites f
WHERE f.user_id = p_id_customer AND f.product_id = p_id_product
),
/* ================= 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 bsp.*
FROM b2b_specific_price bsp
/* RELATIONS */
LEFT JOIN b2b_specific_price_product bsp_p
ON bsp_p.b2b_specific_price_id = bsp.id
LEFT JOIN b2b_specific_price_category bsp_c
ON bsp_c.b2b_specific_price_id = bsp.id
WHERE bsp.is_active = TRUE
/* SCOPE MATCH */
AND (
/* PRODUCT */
(bsp.scope = 'product' AND bsp_p.id_product = p_id_product)
/* CATEGORY */
OR (
bsp.scope = 'category'
AND bsp_c.id_category IN (
SELECT cp.id_category
FROM ps_category_product cp
WHERE cp.id_product = p_id_product
)
)
/* SHOP (GLOBAL) */
OR (bsp.scope = 'shop')
)
/* CUSTOMER MATCH */
AND (
NOT EXISTS (
SELECT 1 FROM b2b_specific_price_customer c
WHERE c.b2b_specific_price_id = bsp.id
)
OR EXISTS (
SELECT 1 FROM b2b_specific_price_customer c
WHERE c.b2b_specific_price_id = bsp.id
AND c.b2b_id_customer = p_id_customer
)
)
/* COUNTRY MATCH */
AND (
NOT EXISTS (
SELECT 1 FROM b2b_specific_price_country ctry
WHERE ctry.b2b_specific_price_id = bsp.id
)
OR EXISTS (
SELECT 1 FROM b2b_specific_price_country ctry
WHERE ctry.b2b_specific_price_id = bsp.id
AND ctry.b2b_id_country = b2b_id_country
)
)
/* QUANTITY */
WHERE bsp.is_active = 1
AND bsp.from_quantity <= p_quantity
/* DATE */
-- intersection rules (unchanged)
AND (
bsp.has_expiration_date = FALSE OR (
(bsp.valid_from IS NULL OR bsp.valid_from <= NOW())
AND (bsp.valid_till IS NULL OR bsp.valid_till >= NOW())
NOT EXISTS (SELECT 1 FROM b2b_specific_price_product x WHERE x.b2b_specific_price_id = bsp.id)
OR EXISTS (SELECT 1 FROM b2b_specific_price_product x WHERE x.b2b_specific_price_id = bsp.id AND x.id_product = p_id_product)
)
AND (
NOT EXISTS (SELECT 1 FROM b2b_specific_price_product_attribute x WHERE x.b2b_specific_price_id = bsp.id)
OR EXISTS (SELECT 1 FROM b2b_specific_price_product_attribute x WHERE x.b2b_specific_price_id = bsp.id AND x.id_product_attribute = p_id_product_attribute)
)
AND (
NOT EXISTS (SELECT 1 FROM b2b_specific_price_category x WHERE x.b2b_specific_price_id = bsp.id)
OR EXISTS (
SELECT 1 FROM b2b_specific_price_category x
JOIN ps_category_product cp ON cp.id_category = x.id_category
WHERE x.b2b_specific_price_id = bsp.id AND cp.id_product = p_id_product
)
)
ORDER BY
/* 🔥 SCOPE PRIORITY */
bsp.scope = 'product' DESC,
bsp.scope = 'category' DESC,
bsp.scope = 'shop' DESC,
AND (
NOT EXISTS (SELECT 1 FROM b2b_specific_price_customer x WHERE x.b2b_specific_price_id = bsp.id)
OR EXISTS (SELECT 1 FROM b2b_specific_price_customer x WHERE x.b2b_specific_price_id = bsp.id AND x.b2b_id_customer = p_id_customer)
)
/* 🔥 CUSTOMER PRIORITY */
(
EXISTS (
SELECT 1 FROM b2b_specific_price_customer c
WHERE c.b2b_specific_price_id = bsp.id
AND c.b2b_id_customer = p_id_customer
)
) DESC,
AND (
NOT EXISTS (SELECT 1 FROM b2b_specific_price_country x WHERE x.b2b_specific_price_id = bsp.id)
OR EXISTS (SELECT 1 FROM b2b_specific_price_country x WHERE x.b2b_specific_price_id = bsp.id AND x.b2b_id_country = p_id_country)
)
/* 🔥 COUNTRY PRIORITY */
(
EXISTS (
SELECT 1 FROM b2b_specific_price_country ctry
WHERE ctry.b2b_specific_price_id = bsp.id
AND ctry.b2b_id_country = b2b_id_country
)
) DESC,
ORDER BY
-- customer wins
(EXISTS (SELECT 1 FROM b2b_specific_price_customer x WHERE x.b2b_specific_price_id = bsp.id AND x.b2b_id_customer = p_id_customer)) DESC,
/* GLOBAL fallback (no restrictions) naturally goes last */
-- attribute
(EXISTS (SELECT 1 FROM b2b_specific_price_product_attribute x WHERE x.b2b_specific_price_id = bsp.id AND x.id_product_attribute = p_id_product_attribute)) DESC,
-- product
(EXISTS (SELECT 1 FROM b2b_specific_price_product x WHERE x.b2b_specific_price_id = bsp.id AND x.id_product = p_id_product)) DESC,
-- category
(EXISTS (
SELECT 1 FROM b2b_specific_price_category x
JOIN ps_category_product cp ON cp.id_category = x.id_category
WHERE x.b2b_specific_price_id = bsp.id AND cp.id_product = p_id_product
)) DESC,
-- country
(EXISTS (SELECT 1 FROM b2b_specific_price_country x WHERE x.b2b_specific_price_id = bsp.id AND x.b2b_id_country = p_id_country)) DESC,
bsp.from_quantity DESC,
bsp.id DESC
LIMIT 1
) bsp ON 1=1
LEFT JOIN b2b_currency_rates br_bsp
ON br_bsp.b2b_id_currency = bsp.b2b_id_currency
AND br_bsp.created_at = (
SELECT MAX(created_at)
FROM b2b_currency_rates
WHERE b2b_id_currency = bsp.b2b_id_currency
)
LEFT JOIN b2b_countries ON b2b_countries.id = b2b_id_country
LEFT JOIN b2b_currencies ON b2b_currencies.id = p_id_currency
LEFT JOIN b2b_currency_rates r ON r.b2b_id_currency = b2b_currencies.id
AND r.created_at = (
SELECT
MAX(created_at)
FROM
b2b_currency_rates
WHERE
b2b_id_currency = b2b_currencies.id
)
WHERE
p.id_product = p_id_product
LIMIT
1;
LIMIT 1;
-- ================= APPLY =================
SET v_excl = v_base;
IF v_has_specific = 1 THEN
IF v_reduction_type = 'amount' THEN
-- convert specific price currency if needed
IF v_specific_currency_id IS NOT NULL AND v_specific_currency_id != v_target_currency THEN
SELECT r.conversion_rate
INTO v_specific_rate
FROM b2b_currency_rates r
WHERE r.b2b_id_currency = v_specific_currency_id
ORDER BY r.created_at DESC
LIMIT 1;
-- normalize → then convert to target
SET v_excl = (v_fixed_price / v_specific_rate) * v_target_rate;
ELSE
SET v_excl = v_fixed_price;
END IF;
ELSEIF v_reduction_type = 'percentage' THEN
SET v_excl = v_base * (1 - v_percentage / 100);
END IF;
END IF;
SET v_incl = v_excl * (1 + v_tax_rate / 100);
RETURN JSON_OBJECT(
'base', v_base,
'final_tax_excl', v_excl,
'final_tax_incl', v_incl,
'tax_rate', v_tax_rate,
'rate', v_target_rate
);
END //
DELIMITER ;
DELIMITER //
DROP PROCEDURE IF EXISTS get_product_variants //
CREATE PROCEDURE get_product_variants(
IN p_id_product INT,
IN p_id_shop INT,
IN p_id_lang INT,
IN p_id_customer INT,
IN p_id_country INT,
IN p_quantity INT
)
BEGIN
SELECT
pa.id_product_attribute,
pa.reference,
-- PRICE (computed once per row via correlated subquery)
CAST(JSON_UNQUOTE(JSON_EXTRACT(
fn_product_price(
p_id_product,
p_id_shop,
p_id_customer,
p_id_country,
p_quantity,
pa.id_product_attribute
),
'$.base'
)) AS DECIMAL(20,6)) AS base_price,
CAST(JSON_UNQUOTE(JSON_EXTRACT(
fn_product_price(
p_id_product,
p_id_shop,
p_id_customer,
p_id_country,
p_quantity,
pa.id_product_attribute
),
'$.final_tax_excl'
)) AS DECIMAL(20,6)) AS price_tax_excl,
CAST(JSON_UNQUOTE(JSON_EXTRACT(
fn_product_price(
p_id_product,
p_id_shop,
p_id_customer,
p_id_country,
p_quantity,
pa.id_product_attribute
),
'$.final_tax_incl'
)) AS DECIMAL(20,6)) AS price_tax_incl,
IFNULL(sa.quantity, 0) AS quantity,
(
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
) AS attributes
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;
END //
DELIMITER ;
DELIMITER //
DROP PROCEDURE IF EXISTS get_product_price //
CREATE PROCEDURE get_product_price(
IN p_id_product INT,
IN p_id_shop INT,
IN p_id_customer INT,
IN p_id_country INT,
IN p_quantity INT
)
BEGIN
SELECT fn_product_price(
p_id_product,
p_id_shop,
p_id_customer,
p_id_country,
p_quantity,
NULL
) AS price;
END //
DELIMITER ;
DELIMITER //
DROP PROCEDURE IF EXISTS get_product_base //
CREATE PROCEDURE get_product_base(
IN p_id_product INT,
IN p_id_shop INT,
IN p_id_lang INT
)
BEGIN
SELECT
p.id_product AS id, -- matches view.Product.ID
p.reference,
p.supplier_reference,
p.ean13,
p.upc,
p.isbn,
-- Price related (basic)
p.price AS base_price,
p.wholesale_price,
p.unity,
p.unit_price_ratio,
-- Stock & Availability
p.quantity,
p.minimal_quantity,
p.available_for_order,
p.available_date,
p.out_of_stock AS out_of_stock_behavior, -- 0=deny, 1=allow, 2=default
-- Flags
COALESCE(ps.on_sale, 0) AS on_sale,
COALESCE(ps.show_price, 1) AS show_price,
p.condition,
p.is_virtual,
-- Physical
p.weight,
p.width,
p.height,
p.depth,
p.additional_shipping_cost,
-- Delivery
p.additional_delivery_times AS delivery_days, -- you can adjust if needed
-- Status
COALESCE(ps.active, p.active) AS active,
COALESCE(ps.visibility, p.visibility) AS visibility,
p.indexed,
-- Other useful
p.date_add,
p.date_upd,
-- Language data
pl.name,
pl.description,
pl.description_short,
-- Relations
m.name AS manufacturer,
cl.name AS category
-- This doesn't fit to base product, I'll add proper is_favorite to product later
-- EXISTS(
-- SELECT 1 FROM b2b_favorites f
-- WHERE f.user_id = p_id_customer AND f.product_id = p_id_product
-- ) AS is_favorite
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
WHERE p.id_product = p_id_product
LIMIT 1;
END //
DELIMITER ;
-- +goose Down

View File

@@ -1,9 +0,0 @@
-- +goose Up
-- +goose StatementBegin
SELECT 'up SQL query';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd