29 Commits

Author SHA1 Message Date
Daniel Goc
bfc488bad8 new fields in meili indexing 2026-03-23 16:43:51 +01:00
Daniel Goc
f5f23f8a27 new fields in meili indexing 2026-03-23 16:42:54 +01:00
cffe4c2f83 Merge pull request 'routing' (#16) from routing into main
Reviewed-on: #16
2026-03-23 13:09:23 +00:00
15e8626280 routing 2026-03-23 14:08:53 +01:00
Daniel Goc
528f12b065 fixed broken HTMLs in ps_product_lang 2026-03-23 12:56:11 +01:00
Daniel Goc
25ad592be3 rename and move folders and files 2026-03-23 09:35:20 +01:00
Daniel Goc
26e6a3c384 setup meili search 2026-03-20 15:18:27 +01:00
a4c1773415 Merge pull request 'fix migrations' (#15) from mailisearch into main
Reviewed-on: #15
2026-03-20 14:02:45 +00:00
8e07daac66 fix migrations 2026-03-20 14:57:50 +01:00
6408b93e5c Merge pull request 'meilisearch' (#14) from mailisearch into main
Reviewed-on: #14
2026-03-20 12:55:52 +00:00
27fa88b076 meilisearch 2026-03-20 13:55:20 +01:00
Daniel Goc
b67c4e3aef endpoint returning tree of categories 2026-03-20 12:38:41 +01:00
Daniel Goc
0d29d8f6a2 debug 2026-03-20 09:57:20 +01:00
Daniel Goc
884e15bb8a added ImageID and LinkRewrite 2026-03-20 09:31:08 +01:00
Daniel Goc
1ea50af96a change returned product list struct 2026-03-19 15:16:08 +01:00
Daniel Goc
b6bf6ed5c6 small debug 2026-03-19 10:39:27 +01:00
7a66d6f429 Merge pull request 'test' (#10) from test into main
Reviewed-on: #10
2026-03-19 08:28:37 +00:00
43f856ee8d fix: errors 2026-03-19 09:26:47 +01:00
506c64e240 Merge branch 'main' of ssh://git.ma-al.com:8822/goc_daniel/b2b into test 2026-03-19 08:17:54 +01:00
718b4d23f1 fix: page Cart 2026-03-18 17:06:16 +01:00
22e8556c9d fix: page Addresses 2026-03-18 16:31:29 +01:00
Daniel Goc
52c17d7017 cleanup 2026-03-18 16:09:03 +01:00
Daniel Goc
e094865fc7 endpoint to update JWT cookies 2026-03-18 15:40:54 +01:00
Daniel Goc
01c8f4333f endpoints returning languages, countries and currencies 2026-03-18 14:12:33 +01:00
Daniel Goc
6cebcacb5d new endpoint to return product list 2026-03-18 11:39:18 +01:00
c79e08dbb8 Merge branch 'main' of ssh://git.ma-al.com:8822/goc_daniel/b2b into test 2026-03-18 10:02:44 +01:00
789d59b0c9 fix: added page PageProductCardFull and Addresses 2026-03-17 14:12:02 +01:00
7388d0f828 fix: added button cansel. 2026-03-17 10:59:09 +01:00
Daniel Goc
a0dcb56fda code refactor 2026-03-17 10:55:17 +01:00
75 changed files with 7136 additions and 595 deletions

8
.env
View File

@@ -21,6 +21,13 @@ AUTH_JWT_SECRET=5c020e6ed3d8d6e67e5804d67c83c4bd5ae474df749af6d63d8f20e7e2ba29b3
AUTH_JWT_EXPIRATION=86400 AUTH_JWT_EXPIRATION=86400
AUTH_REFRESH_EXPIRATION=604800 AUTH_REFRESH_EXPIRATION=604800
# Meili search
MEILISEARCH_URL=http://localhost:7700
MEILISEARCH_API_KEY=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
# OpenAI
OPENAI_KEY=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
# Google Translate Client # Google Translate Client
GOOGLE_APPLICATION_CREDENTIALS=./google-cred.json GOOGLE_APPLICATION_CREDENTIALS=./google-cred.json
GOOGLE_CLOUD_PROJECT_ID=translation-343517 GOOGLE_CLOUD_PROJECT_ID=translation-343517
@@ -29,6 +36,7 @@ GOOGLE_CLOUD_PROJECT_ID=translation-343517
OAUTH_GOOGLE_CLIENT_ID=331979954218-9vrpe08oqhhcgj6bvu6d4lds0dt630m9.apps.googleusercontent.com OAUTH_GOOGLE_CLIENT_ID=331979954218-9vrpe08oqhhcgj6bvu6d4lds0dt630m9.apps.googleusercontent.com
OAUTH_GOOGLE_CLIENT_SECRET=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 OAUTH_GOOGLE_CLIENT_SECRET=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
OAUTH_GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/public/auth/google/callback OAUTH_GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/public/auth/google/callback
# Email Configuration (SMTP) # Email Configuration (SMTP)
# Set EMAIL_ENABLED=true to require email verification # Set EMAIL_ENABLED=true to require email verification
EMAIL_ENABLED=true EMAIL_ENABLED=true

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ assets/public/dist
bin/ bin/
i18n/*.json i18n/*.json
*_templ.go *_templ.go
tmp/main tmp/main
test.go

2995
ADD_THIS_TO_SQL.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -72,11 +72,23 @@ vars:
MP_SMTP_AUTH_ACCEPT_ANY: true MP_SMTP_AUTH_ACCEPT_ANY: true
MP_SMTP_AUTH_ALLOW_INSECURE: true MP_SMTP_AUTH_ALLOW_INSECURE: true
MP_ENABLE_SPAMASSASSIN: postmark MP_ENABLE_SPAMASSASSIN: postmark
MP_VERBOSE: true MP_VERBOSE: true
meilisearch:
image: getmeili/meilisearch:latest
container_name: meilisearch
restart: unless-stopped
ports:
- 7700:7700
volumes:
- meilisearch:/data.ms
environment:
MEILI_MASTER_KEY: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
volumes: volumes:
db_data: db_data:
mailpit_data: mailpit_data:
meilisearch:
includes: includes:

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
package restricted
import (
"git.ma-al.com/goc_daniel/b2b/app/service/localeSelectorService"
"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"
)
// LocaleSelectorHandler for getting languages and countries data
type LocaleSelectorHandler struct {
localeSelectorService *localeSelectorService.LocaleSelectorService
}
// NewLocaleSelectorHandler creates a new LocaleSelectorHandler instance
func NewLocaleSelectorHandler() *LocaleSelectorHandler {
localeSelectorService := localeSelectorService.New()
return &LocaleSelectorHandler{
localeSelectorService: localeSelectorService,
}
}
func LocaleSelectorHandlerRoutes(r fiber.Router) fiber.Router {
handler := NewLocaleSelectorHandler()
r.Get("/get-languages", handler.GetLanguages)
r.Get("/get-countries", handler.GetCountries)
return r
}
func (h *LocaleSelectorHandler) GetLanguages(c fiber.Ctx) error {
languages, err := h.localeSelectorService.GetLanguages()
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&languages, 0, i18n.T_(c, response.Message_OK)))
}
func (h *LocaleSelectorHandler) GetCountries(c fiber.Ctx) error {
countries, err := h.localeSelectorService.GetCountriesAndCurrencies()
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&countries, 0, i18n.T_(c, response.Message_OK)))
}

View File

@@ -0,0 +1,63 @@
package restricted
import (
"git.ma-al.com/goc_daniel/b2b/app/service/meiliService"
"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 MeiliSearchHandler struct {
meiliService *meiliService.MeiliService
}
func NewMeiliSearchHandler() *MeiliSearchHandler {
meiliService := meiliService.New()
return &MeiliSearchHandler{
meiliService: meiliService,
}
}
func MeiliSearchHandlerRoutes(r fiber.Router) fiber.Router {
handler := NewMeiliSearchHandler()
r.Get("/test", handler.Test)
r.Get("/create-index", handler.CreateIndex)
return r
}
func (h *MeiliSearchHandler) CreateIndex(c fiber.Ctx) error {
id_lang, ok := c.Locals("langID").(uint)
if !ok {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
err := h.meiliService.CreateIndex(id_lang)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
nothing := ""
return c.JSON(response.Make(&nothing, 0, i18n.T_(c, response.Message_OK)))
}
func (h *MeiliSearchHandler) Test(c fiber.Ctx) error {
id_lang, ok := c.Locals("langID").(uint)
if !ok {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
test, err := h.meiliService.Test(id_lang)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&test, 0, i18n.T_(c, response.Message_OK)))
}

View File

@@ -0,0 +1,45 @@
package restricted
import (
"git.ma-al.com/goc_daniel/b2b/app/service/menuService"
"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 MenuHandler struct {
menuService *menuService.MenuService
}
func NewMenuHandler() *MenuHandler {
menuService := menuService.New()
return &MenuHandler{
menuService: menuService,
}
}
func MenuHandlerRoutes(r fiber.Router) fiber.Router {
handler := NewMenuHandler()
r.Get("/get-menu", handler.GetMenu)
return r
}
func (h *MenuHandler) GetMenu(c fiber.Ctx) error {
id_lang, ok := c.Locals("langID").(uint)
if !ok {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
menu, err := h.menuService.GetMenu(id_lang)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&menu, 0, i18n.T_(c, response.Message_OK)))
}

View File

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

View File

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

View File

@@ -89,10 +89,27 @@ func (s *Server) Setup() error {
auth := s.public.Group("/auth") auth := s.public.Group("/auth")
public.AuthHandlerRoutes(auth) public.AuthHandlerRoutes(auth)
// Repo routes (restricted) // product description routes (restricted)
productDescription := s.restricted.Group("/product-description") productDescription := s.restricted.Group("/product-description")
restricted.ProductDescriptionHandlerRoutes(productDescription) restricted.ProductDescriptionHandlerRoutes(productDescription)
// listing products routes (restricted)
listProducts := s.restricted.Group("/list-products")
restricted.ListProductsHandlerRoutes(listProducts)
// locale selector (restricted)
// this is basically for changing user's selected language and country
localeSelector := s.restricted.Group("/langs-and-countries")
restricted.LocaleSelectorHandlerRoutes(localeSelector)
// menu (restricted)
menu := s.restricted.Group("/menu")
restricted.MenuHandlerRoutes(menu)
// meili search (restricted)
meiliSearch := s.restricted.Group("/meili-search")
restricted.MeiliSearchHandlerRoutes(meiliSearch)
// // Restricted routes example // // Restricted routes example
// restricted := s.api.Group("/restricted") // restricted := s.api.Group("/restricted")
// restricted.Use(middleware.AuthMiddleware()) // restricted.Use(middleware.AuthMiddleware())

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

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

View File

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

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

@@ -0,0 +1,108 @@
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:ID;primaryKey" json:"product_id" form:"product_id"`
Name string `gorm:"column:name" json:"name" form:"name"`
ImageID uint `gorm:"column:id_image"`
LinkRewrite string `gorm:"column:link_rewrite"`
Active uint `gorm:"column:active" json:"active" form:"active"`
}
type ProductFilters struct {
Sort string `json:"sort,omitempty" query:"sort,omitempty" example:"price,asc;name,desc"` // sort rule
ProductID uint `json:"product_id,omitempty" query:"product_id,omitempty" example:"1"`
Price float64 `json:"price,omitempty" query:"price,omitempty" example:"123.45"`
Name string `json:"name,omitempty" query:"name,omitempty" example:"Sztabka Złota Britannia"`
CategoryID uint `json:"category_id,omitempty" query:"category_id,omitempty" example:"2"`
CategoryName string `json:"category_name,omitempty" query:"category_name,omitempty" example:"Złote Monety"`
Features FeatVal `query:"features,omitempty"`
ActiveSale bool `query:"sale_active,omitempty"`
InStock uint `query:"stock,omitempty"`
}
type ScannedCategory struct {
CategoryID uint `gorm:"column:ID;primaryKey"`
Name string `gorm:"column:name"`
Active uint `gorm:"column:active"`
Position uint `gorm:"column:position"`
ParentID uint `gorm:"column:id_parent"`
IsRoot uint `gorm:"column:is_root_category"`
LinkRewrite string `gorm:"column:link_rewrite"`
IsoCode string `gorm:"column:iso_code"`
}
type Category struct {
CategoryID uint `json:"category_id" form:"category_id"`
Label string `json:"label" form:"label"`
// Active bool `json:"active" form:"active"`
Params CategpryParams `json:"params" form:"params"`
Children []Category `json:"children" form:"children"`
}
type CategpryParams struct {
CategoryID uint `json:"category_id" form:"category_id"`
LinkRewrite string `json:"link_rewrite" form:"link_rewrite"`
Locale string `json:"locale" form:"locale"`
}
type FeatVal = map[uint][]uint

View File

@@ -18,3 +18,27 @@ type ProductDescription struct {
DeliveryOutStock string `gorm:"column:delivery_out_stock;type:varchar(255)" json:"delivery_out_stock" form:"delivery_out_stock"` DeliveryOutStock string `gorm:"column:delivery_out_stock;type:varchar(255)" json:"delivery_out_stock" form:"delivery_out_stock"`
Usage string `gorm:"column:usage;type:text" json:"usage" form:"usage"` Usage string `gorm:"column:usage;type:text" json:"usage" form:"usage"`
} }
type ProductRow struct {
IDProduct int `gorm:"column:id_product"`
IDShop int `gorm:"column:id_shop"`
Name string `gorm:"column:name"`
Active uint8 `gorm:"column:active"`
Reference string `gorm:"column:reference"`
}
type MeiliSearchProduct struct {
ProductID uint `gorm:"column:id_product"`
Name string `gorm:"column:name"`
Active uint8 `gorm:"column:active"`
Price float64 `gorm:"column:price"`
Description string `gorm:"column:description"`
DescriptionShort string `gorm:"column:description_short"`
Usage string `gorm:"column:usage"`
EAN13 string `gorm:"column:ean13"`
Reference string `gorm:"column:reference"`
Width float64 `gorm:"column:width"`
Height float64 `gorm:"column:height"`
Depth float64 `gorm:"column:depth"`
Weight float64 `gorm:"column:weight"`
}

View File

@@ -0,0 +1,41 @@
package categoriesRepo
import (
"git.ma-al.com/goc_daniel/b2b/app/db"
"git.ma-al.com/goc_daniel/b2b/app/model"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
)
type UICategoriesRepo interface {
GetAllCategories(id_lang uint) ([]model.ScannedCategory, error)
}
type CategoriesRepo struct{}
func New() UICategoriesRepo {
return &CategoriesRepo{}
}
func (repo *CategoriesRepo) GetAllCategories(id_lang uint) ([]model.ScannedCategory, error) {
var allCategories []model.ScannedCategory
err := db.DB.Raw(`
SELECT
ps_category.id_category AS ID,
ps_category_lang.name AS name,
ps_category.active AS active,
ps_category_shop.position AS position,
ps_category.id_parent AS id_parent,
ps_category.is_root_category AS is_root_category,
ps_category_lang.link_rewrite AS link_rewrite,
ps_lang.iso_code AS iso_code
FROM ps_category
LEFT JOIN ps_category_lang ON ps_category_lang.id_category = ps_category.id_category AND ps_category_lang.id_shop = ? AND ps_category_lang.id_lang = ?
LEFT JOIN ps_category_shop ON ps_category_shop.id_category = ps_category.id_category AND ps_category_shop.id_shop = ?
JOIN ps_lang ON ps_lang.id_lang = ps_category_lang.id_lang
`,
constdata.SHOP_ID, id_lang, constdata.SHOP_ID).
Scan(&allCategories).Error
return allCategories, err
}

View File

@@ -0,0 +1,82 @@
package listProductsRepo
import (
"git.ma-al.com/goc_daniel/b2b/app/db"
"git.ma-al.com/goc_daniel/b2b/app/model"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/filters"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/find"
)
type UIListProductsRepo interface {
GetListing(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error)
}
type ListProductsRepo struct{}
func New() UIListProductsRepo {
return &ListProductsRepo{}
}
func (repo *ListProductsRepo) GetListing(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) {
var listing []model.ProductInList
var total int64
// var resultIDs []uint
// q := db.DB.
// // SQL_CALC_FOUND_ROWS is a neat trick which works on MariaDB and
// // MySQL. It works when followed by `SELECT FOUND_ROWS();`. To learn
// // more see: https://mariarawmodel.com/kb/en/found_rows/
// // WARN: This might not work on different SQL databases
// Select("DISTINCT SQL_CALC_FOUND_ROWS id").
// // Debug().
// Scopes(view.FromDBViewForDisplay(langID, countryIso)).
// Scopes(scopesForFiltersOnDisplay(db.DB, langID, countryIso, filt)).
// Scopes(filt.OfCategory(filters.ORDER_FILTER)...).
// Limit(p.Limit()).
// Offset(p.Offset())
err := db.DB.Raw(`
SELECT
ps_product.id_product AS ID,
ps_product_lang.name AS name,
ps_product.active AS active,
ps_product_lang.link_rewrite AS link_rewrite,
COALESCE (
ps_image_shop.id_image, any_image.id_image
) AS id_image
FROM ps_product
LEFT JOIN ps_product_lang
ON ps_product_lang.id_product = ps_product.id_product
AND ps_product_lang.id_shop = ?
AND ps_product_lang.id_lang = ?
LEFT JOIN ps_image_shop
ON ps_image_shop.id_product = ps_product.id_product
AND ps_image_shop.id_shop = ?
AND ps_image_shop.cover = 1
LEFT JOIN (
SELECT id_product, MIN(id_image) AS id_image
FROM ps_image
GROUP BY id_product
) any_image
ON ps_product.id_product = any_image.id_product
LIMIT ? OFFSET ?`,
constdata.SHOP_ID, id_lang, constdata.SHOP_ID, p.Limit(), p.Offset()).
Scan(&listing).Error
if err != nil {
return find.Found[model.ProductInList]{}, err
}
err = db.DB.Raw(`
SELECT COUNT(*)
FROM ps_product`).
Scan(&total).Error
if err != nil {
return find.Found[model.ProductInList]{}, err
}
return find.Found[model.ProductInList]{
Items: listing,
Count: uint(total),
}, nil
}

View File

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

View File

@@ -0,0 +1,108 @@
package productDescriptionRepo
import (
"fmt"
"git.ma-al.com/goc_daniel/b2b/app/db"
"git.ma-al.com/goc_daniel/b2b/app/model"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
)
type UIProductDescriptionRepo interface {
GetProductDescription(productID uint, productLangID uint) (*model.ProductDescription, error)
CreateIfDoesNotExist(productID uint, productLangID uint) error
UpdateFields(productID uint, productLangID uint, updates map[string]string) error
GetMeiliProducts(id_lang uint) ([]model.MeiliSearchProduct, error)
}
type ProductDescriptionRepo struct{}
func New() UIProductDescriptionRepo {
return &ProductDescriptionRepo{}
}
// We assume that any user has access to all product descriptions
func (r *ProductDescriptionRepo) GetProductDescription(productID uint, productLangID uint) (*model.ProductDescription, error) {
var ProductDescription model.ProductDescription
err := db.DB.
Table("ps_product_lang").
Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, constdata.SHOP_ID, productLangID).
First(&ProductDescription).Error
if err != nil {
return nil, fmt.Errorf("database error: %w", err)
}
return &ProductDescription, nil
}
// If it doesn't exist, returns an error.
func (r *ProductDescriptionRepo) CreateIfDoesNotExist(productID uint, productLangID uint) error {
record := model.ProductDescription{
ProductID: productID,
ShopID: constdata.SHOP_ID,
LangID: productLangID,
}
err := db.DB.
Table("ps_product_lang").
Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, constdata.SHOP_ID, productLangID).
FirstOrCreate(&record).Error
if err != nil {
return fmt.Errorf("database error: %w", err)
}
return nil
}
func (r *ProductDescriptionRepo) UpdateFields(productID uint, productLangID uint, updates map[string]string) error {
if len(updates) == 0 {
return nil
}
updatesIface := make(map[string]interface{}, len(updates))
for k, v := range updates {
updatesIface[k] = v
}
err := db.DB.
Table("ps_product_lang").
Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, constdata.SHOP_ID, productLangID).
Updates(updatesIface).Error
if err != nil {
return fmt.Errorf("database error: %w", err)
}
return nil
}
// We assume that any user has access to all product descriptions
func (r *ProductDescriptionRepo) GetMeiliProducts(id_lang uint) ([]model.MeiliSearchProduct, error) {
var products []model.MeiliSearchProduct
err := db.DB.
Select("pl.`usage` AS `usage`").
Select(`
ps.id_product AS id_product,
pl.name AS name,
ps.active AS active,
ps.price AS price,
pl.description AS description,
pl.description_short AS description_short,
p.ean13 AS ean13,
p.reference AS reference,
p.width AS width,
p.height AS height,
p.depth AS depth,
p.weight AS weight
`).
Table("ps_product_shop AS ps").
Joins("LEFT JOIN ps_product_lang AS pl ON ps.id_product = pl.id_product AND pl.id_shop = ? AND pl.id_lang = ?", constdata.SHOP_ID, id_lang).
Joins("LEFT JOIN ps_product AS p ON p.id_product = ps.id_product").
Where("ps.id_shop = ?", constdata.SHOP_ID).
Scan(&products).Error
if err != nil {
return products, fmt.Errorf("database error: %w", err)
}
return products, nil
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
package localeSelectorService
import (
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/repos/localeSelectorRepo"
)
// LocaleSelectorService literally sends back language and countries information.
type LocaleSelectorService struct {
repo localeSelectorRepo.UILocaleSelectorRepo
}
// NewLocaleSelectorService creates a new LocaleSelector service
func New() *LocaleSelectorService {
return &LocaleSelectorService{
repo: localeSelectorRepo.New(),
}
}
func (s *LocaleSelectorService) GetLanguages() ([]model.Language, error) {
return s.repo.GetLanguages()
}
func (s *LocaleSelectorService) GetCountriesAndCurrencies() ([]model.Country, error) {
return s.repo.GetCountriesAndCurrencies()
}

View File

@@ -0,0 +1,8 @@
{
"products-openai": {
"source": "openAi",
"model": "text-embedding-3-small",
"apiKey": "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",
"documentTemplate": "{{doc.Name}} is equipment used for {{doc.Description | truncatewords: 20}}"
}
}

View File

@@ -0,0 +1,170 @@
package meiliService
import (
"encoding/xml"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"git.ma-al.com/goc_daniel/b2b/app/repos/productDescriptionRepo"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
"github.com/meilisearch/meilisearch-go"
)
type MeiliService struct {
productDescriptionRepo productDescriptionRepo.UIProductDescriptionRepo
meiliClient meilisearch.ServiceManager
}
func New() *MeiliService {
meiliURL := os.Getenv("MEILISEARCH_URL")
meiliAPIKey := os.Getenv("MEILISEARCH_API_KEY")
client := meilisearch.New(
meiliURL,
meilisearch.WithAPIKey(meiliAPIKey),
)
return &MeiliService{
meiliClient: client,
productDescriptionRepo: productDescriptionRepo.New(),
}
}
// ==================================== FOR SUPERADMIN ONLY ====================================
func (s *MeiliService) CreateIndex(id_lang uint) error {
products, err := s.productDescriptionRepo.GetMeiliProducts(id_lang)
for i := 0; i < len(products); i++ {
products[i].Description, err = cleanHTML(products[i].Description)
if err != nil {
fmt.Printf("products[i].Description: %v\n", products[i].Description)
fmt.Printf("products[i].ProductID: %v\n", products[i].ProductID)
fmt.Println("failed at description")
fmt.Printf("err: %v\n", err)
return err
}
products[i].DescriptionShort, err = cleanHTML(products[i].DescriptionShort)
if err != nil {
fmt.Printf("products[i].DescriptionShort: %v\n", products[i].DescriptionShort)
fmt.Printf("products[i].ProductID: %v\n", products[i].ProductID)
fmt.Println("failed at description short")
fmt.Printf("err: %v\n", err)
return err
}
products[i].Usage, err = cleanHTML(products[i].Usage)
if err != nil {
fmt.Printf("products[i].Usage: %v\n", products[i].Usage)
fmt.Printf("products[i].ProductID: %v\n", products[i].ProductID)
fmt.Println("failed at usage")
fmt.Printf("err: %v\n", err)
return err
}
}
indexName := "meili_products_shop" + strconv.FormatInt(constdata.SHOP_ID, 10) + "_lang" + strconv.FormatInt(int64(id_lang), 10)
primaryKey := "ProductID"
docOptions := &meilisearch.DocumentOptions{
PrimaryKey: &primaryKey,
SkipCreation: false,
}
task, err := s.meiliClient.Index(indexName).AddDocuments(products, docOptions)
if err != nil {
return fmt.Errorf("meili AddDocuments error: %w", err)
}
finishedTask, err := s.meiliClient.WaitForTask(task.TaskUID, 500*time.Millisecond)
fmt.Printf("Task status: %s\n", finishedTask.Status)
fmt.Printf("Task error: %s\n", finishedTask.Error)
return err
}
// ==================================== FOR DEBUG ONLY ====================================
func (s *MeiliService) Test(id_lang uint) (meilisearch.SearchResponse, error) {
indexName := "meili_products_shop" + strconv.FormatInt(constdata.SHOP_ID, 10) + "_lang" + strconv.FormatInt(int64(id_lang), 10)
searchReq := &meilisearch.SearchRequest{
Limit: 3,
}
// Perform search
results, err := s.meiliClient.Index(indexName).Search("walek", searchReq)
if err != nil {
fmt.Printf("Meilisearch error: %v\n", err)
return meilisearch.SearchResponse{}, err
}
fmt.Printf("Search results for query 'walek' in %s: %d hits\n", indexName, len(results.Hits))
return *results, nil
}
// Search performs a full-text search on the specified index
func (s *MeiliService) Search(indexName string, query string, limit int) (meilisearch.SearchResponse, error) {
searchReq := &meilisearch.SearchRequest{
Limit: int64(limit),
}
results, err := s.meiliClient.Index(indexName).Search(query, searchReq)
if err != nil {
fmt.Printf("Meilisearch search error: %v\n", err)
return meilisearch.SearchResponse{}, err
}
return *results, nil
}
// HealthCheck checks if Meilisearch is healthy and accessible
func (s *MeiliService) HealthCheck() (*meilisearch.Health, error) {
health, err := s.meiliClient.Health()
if err != nil {
return nil, fmt.Errorf("meilisearch health check failed: %w", err)
}
return health, nil
}
// remove all tags from HTML text
func cleanHTML(s string) (string, error) {
r := strings.NewReader(s)
d := xml.NewDecoder(r)
text := ""
// Configure the decoder for HTML; leave off strict and autoclose for XHTML
d.Strict = true
d.AutoClose = xml.HTMLAutoClose
d.Entity = xml.HTMLEntity
for {
token, err := d.Token()
if err == io.EOF {
break
} else if err != nil {
return text, err
}
switch v := token.(type) {
case xml.StartElement:
if len(text) > 0 && text[len(text)-1] != '\n' {
text += " \n "
}
case xml.EndElement:
case xml.CharData:
if strings.TrimSpace(string(v)) != "" {
text += string(v)
}
case xml.Comment:
case xml.ProcInst:
case xml.Directive:
}
}
return text, nil
}

View File

@@ -0,0 +1,92 @@
package menuService
import (
"sort"
"git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/repos/categoriesRepo"
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
)
type MenuService struct {
categoriesRepo categoriesRepo.UICategoriesRepo
}
func New() *MenuService {
return &MenuService{
categoriesRepo: categoriesRepo.New(),
}
}
func (s *MenuService) GetMenu(id_lang uint) (model.Category, error) {
all_categories, err := s.categoriesRepo.GetAllCategories(id_lang)
if err != nil {
return model.Category{}, err
}
// find the root
root_index := 0
root_found := false
for i := 0; i < len(all_categories); i++ {
if all_categories[i].IsRoot == 1 {
root_index = i
root_found = true
break
}
}
if !root_found {
return model.Category{}, responseErrors.ErrNoRootFound
}
// now create the children and reorder them according to position
id_to_index := make(map[uint]int)
for i := 0; i < len(all_categories); i++ {
id_to_index[all_categories[i].CategoryID] = i
}
children_indices := make(map[int][]ChildWithPosition)
for i := 0; i < len(all_categories); i++ {
parent_index := id_to_index[all_categories[i].ParentID]
children_indices[parent_index] = append(children_indices[parent_index], ChildWithPosition{Index: i, Position: all_categories[i].Position})
}
for key := range children_indices {
sort.Sort(ByPosition(children_indices[key]))
}
// finally, create the tree
tree := s.createTree(root_index, &all_categories, &children_indices)
return tree, nil
}
func (s *MenuService) createTree(index int, all_categories *([]model.ScannedCategory), children_indices *(map[int][]ChildWithPosition)) model.Category {
node := s.scannedToNormalCategory((*all_categories)[index])
for i := 0; i < len((*children_indices)[index]); i++ {
node.Children = append(node.Children, s.createTree((*children_indices)[index][i].Index, all_categories, children_indices))
}
return node
}
func (s *MenuService) scannedToNormalCategory(scanned model.ScannedCategory) model.Category {
var normal model.Category
// normal.Active = scanned.Active
normal.CategoryID = scanned.CategoryID
normal.Label = scanned.Name
// normal.Active = scanned.Active == 1
normal.Params = model.CategpryParams{CategoryID: normal.CategoryID, LinkRewrite: scanned.LinkRewrite, Locale: scanned.IsoCode}
normal.Children = []model.Category{}
return normal
}
type ChildWithPosition struct {
Index int
Position uint
}
type ByPosition []ChildWithPosition
func (a ByPosition) Len() int { return len(a) }
func (a ByPosition) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByPosition) Less(i, j int) bool { return a[i].Position < a[j].Position }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

10
bo/components.d.ts vendored
View File

@@ -16,8 +16,15 @@ declare module 'vue' {
En_PrivacyPolicyView: typeof import('./src/components/terms/en_PrivacyPolicyView.vue')['default'] En_PrivacyPolicyView: typeof import('./src/components/terms/en_PrivacyPolicyView.vue')['default']
En_TermsAndConditionsView: typeof import('./src/components/terms/en_TermsAndConditionsView.vue')['default'] En_TermsAndConditionsView: typeof import('./src/components/terms/en_TermsAndConditionsView.vue')['default']
LangSwitch: typeof import('./src/components/inner/langSwitch.vue')['default'] LangSwitch: typeof import('./src/components/inner/langSwitch.vue')['default']
PageAddresses: typeof import('./src/components/customer/PageAddresses.vue')['default']
PageCart: typeof import('./src/components/customer/PageCart.vue')['default']
PageProductCardFull: typeof import('./src/components/customer/PageProductCardFull.vue')['default']
Pl_PrivacyPolicyView: typeof import('./src/components/terms/pl_PrivacyPolicyView.vue')['default'] Pl_PrivacyPolicyView: typeof import('./src/components/terms/pl_PrivacyPolicyView.vue')['default']
Pl_TermsAndConditionsView: typeof import('./src/components/terms/pl_TermsAndConditionsView.vue')['default'] Pl_TermsAndConditionsView: typeof import('./src/components/terms/pl_TermsAndConditionsView.vue')['default']
ProductCustomization: typeof import('./src/components/customer/components/ProductCustomization.vue')['default']
ProductDetailView: typeof import('./src/components/admin/ProductDetailView.vue')['default']
ProductsView: typeof import('./src/components/admin/ProductsView.vue')['default']
ProductVariants: typeof import('./src/components/customer/components/ProductVariants.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
ThemeSwitch: typeof import('./src/components/inner/themeSwitch.vue')['default'] ThemeSwitch: typeof import('./src/components/inner/themeSwitch.vue')['default']
@@ -32,6 +39,9 @@ declare module 'vue' {
UFormField: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default'] UFormField: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
UIcon: typeof import('./node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default'] UIcon: typeof import('./node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
UInput: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default'] UInput: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
UInputNumber: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/InputNumber.vue')['default']
UModal: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
UNavigationMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/NavigationMenu.vue')['default']
UPagination: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Pagination.vue')['default'] UPagination: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Pagination.vue')['default']
USelect: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Select.vue')['default'] USelect: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Select.vue')['default']
USelectMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default'] USelectMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']

View File

@@ -2,10 +2,6 @@ import type { NuxtUIOptions } from '@nuxt/ui/unplugin'
export const uiOptions: NuxtUIOptions = { export const uiOptions: NuxtUIOptions = {
ui: { ui: {
colors: {
primary: 'blue',
neutral: 'zink',
},
pagination: { pagination: {
slots: { slots: {
root: '', root: '',
@@ -22,6 +18,13 @@ export const uiOptions: NuxtUIOptions = {
error: 'text-red-600!' error: 'text-red-600!'
}, },
}, },
inputNumber: {
slots: {
base: 'text-(--black) dark:text-white border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! pt-2 px-1! w-auto!',
increment: 'border-0! pe-0! ps-0!',
decrement: 'border-0! pe-0! ps-0!'
},
},
select: { select: {
slots: { slots: {
base: 'w-full! cursor-pointer border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0!', base: 'w-full! cursor-pointer border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0!',
@@ -43,12 +46,18 @@ export const uiOptions: NuxtUIOptions = {
} }
}, },
table: { table: {
slots: { slots: {
base: 'border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! bg-(--second-light) dark:bg-(--main-dark)', 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!',
} }
},
modal: {
slots: {
content: 'border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! bg-(--second-light) dark:bg-(--main-dark)',
}
} }
} }
} }

View File

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

View File

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

View File

@@ -1,14 +1,15 @@
<template> <template>
<div class="container my-10 "> <div class="container my-10 mx-auto ">
<div class="flex items-end justify-between gap-4 mb-6 bg-(--second-light) dark:bg-(--main-dark) border border-(--border-light) dark:border-(--border-dark) p-4 rounded-md"> <div
class="flex items-end justify-between gap-4 mb-6 bg-(--second-light) dark:bg-(--main-dark) border border-(--border-light) dark:border-(--border-dark) p-4 rounded-md">
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
<USelect v-model="selectedLanguage" :items="availableLangs" variant="outline" class="w-40!" <USelect v-model="selectedLanguage" :items="availableLangs" variant="outline" class="w-40!" valueKey="iso_code">
valueKey="iso_code">
<template #default="{ modelValue }"> <template #default="{ modelValue }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-md">{{ availableLangs.find(x => x.iso_code == modelValue)?.flag }}</span> <span class="text-md">{{availableLangs.find(x => x.iso_code == modelValue)?.flag}}</span>
<span class="font-medium dark:text-white text-black">{{ availableLangs.find(x => x.iso_code == modelValue)?.name }}</span> <span class="font-medium dark:text-white text-black">{{availableLangs.find(x => x.iso_code ==
modelValue)?.name}}</span>
</div> </div>
</template> </template>
<template #item-leading="{ item }"> <template #item-leading="{ item }">
@@ -19,7 +20,8 @@
</template> </template>
</USelect> </USelect>
</div> </div>
<UButton @click="translateToSelectedLanguage" color="primary" :loading="translating" class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) px-12!"> <UButton @click="translateToSelectedLanguage" color="primary" :loading="translating"
class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) px-12!">
Translate Translate
</UButton> </UButton>
</div> </div>
@@ -33,23 +35,24 @@
</div> </div>
<div class="flex items-start gap-30"> <div class="flex items-start gap-30">
<div class="flex flex-col gap-10"> <p class="p-80 bg-(--second-light)">img</p>
<p class="p-60 bg-yellow-300">img</p>
</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p class="text-[25px] font-bold dark:text-white text-black">{{ productStore.productDescription.name }}</p> <p class="text-[25px] font-bold text-black dark:text-white">
<p v-html="productStore.productDescription.description_short" class="dark:text-white text-black"></p> {{ productStore.productDescription.name }}
</p>
<p v-html="productStore.productDescription.description_short" class="text-black dark:text-white"></p>
<div class="space-[10px]"> <div class="space-y-[10px]">
<div class="flex gap-1 items-center"> <div class="flex items-center gap-1">
<UIcon name="lets-icons:done-ring-round-fill" class="text-[20px] text-green-600" /> <UIcon name="lets-icons:done-ring-round-fill" class="text-[20px] text-green-600" />
<p class=" gap-1text-[16px] font-bold text-(--accent-blue-light) dark:text-(--accent-blue-dark)">{{ <p class="text-[16px] font-bold text-(--accent-blue-light) dark:text-(--accent-blue-dark)">
productStore.productDescription.available_now }}</p> {{ productStore.productDescription.available_now }}
</p>
</div> </div>
<div class="flex gap-1 items-center"> <div class="flex items-center gap-1">
<UIcon name="marketeq:car-shipping" class="text-[25px] text-green-600" /> <UIcon name="marketeq:car-shipping" class="text-[25px] text-green-600" />
<p class="text-[18px] font-bold dark:text-white text-black">{{ productStore.productDescription.delivery_in_stock }}</p> <p class="text-[18px] font-bold text-black dark:text-white">
{{ productStore.productDescription.delivery_in_stock }}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -70,30 +73,39 @@
</UButton> </UButton>
</div> </div>
<div v-if="activeTab === 'usage'" class="px-8 py-4 border border-(--border-light) dark:border-(--border-dark) rounded-md bg-(--second-light) dark:bg-(--main-dark)"> <div v-if="activeTab === 'usage'"
<div class="flex justify-end items-center gap-3 mb-4"> class="px-8 py-4 border border-(--border-light) dark:border-(--border-dark) rounded-md bg-(--second-light) dark:bg-(--main-dark)">
<UButton @click="usageEdit.enableEdit()" class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
<p class="text-white ">Change Text</p>
<UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-white!" />
</UButton>
<UButton @click="save" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
<p class="dark:text-white text-black ">Save the edited text</p>
</UButton>
</div>
<p ref="usageRef" v-html="productStore.productDescription.usage"
class="flex flex-col justify-center w-full text-start dark:text-white text-black">
</p>
</div>
<div v-if="activeTab === 'description'" class="px-8 py-4 border border-(--border-light) dark:border-(--border-dark) rounded-md bg-(--second-light) dark:bg-(--main-dark)"> <div class="flex justify-end items-center gap-3 mb-4">
<div class="flex items-center justify-end gap-3 mb-4"> <UButton v-if="!isEditing" @click="enableEdit"
<UButton @click="descriptionEdit.enableEdit()" class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!"> class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
<p class="text-white">Change Text</p> <p class="text-white">Change Text</p>
<UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-white!" /> <UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-white!" />
</UButton> </UButton>
<UButton @click="save" color="neutral" variant="outline" class="p-2.5 cursor-pointer"> <UButton v-if="isEditing" @click="saveText" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
<p class="dark:text-white text-black">Save the edited text</p>
</UButton>
<UButton v-if="isEditing" @click="cancelEdit" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
Cancel
</UButton>
</div>
<p ref="usageRef" v-html="productStore.productDescription.usage"
class="flex flex-col justify-center w-full text-start dark:text-white! text-black!"></p>
</div>
<div v-if="activeTab === 'description'"
class="px-8 py-4 border border-(--border-light) dark:border-(--border-dark) rounded-md bg-(--second-light) dark:bg-(--main-dark)">
<div class="flex items-center justify-end gap-3 mb-4">
<UButton v-if="!descriptionEdit.isEditing.value" @click="enableDescriptionEdit"
class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
<p class="text-white">Change Text</p>
<UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-white!" />
</UButton>
<UButton v-if="descriptionEdit.isEditing.value" @click="saveDescription" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
<p class="dark:text-white text-black ">Save the edited text</p> <p class="dark:text-white text-black ">Save the edited text</p>
</UButton> </UButton>
<UButton v-if="descriptionEdit.isEditing.value" @click="cancelDescriptionEdit" color="neutral" variant="outline" class="p-2.5 cursor-pointer">Cancel</UButton>
</div> </div>
<div ref="descriptionRef" v-html="productStore.productDescription.description" <div ref="descriptionRef" v-html="productStore.productDescription.description"
class="flex flex-col justify-center dark:text-white text-black"> class="flex flex-col justify-center dark:text-white text-black">
@@ -118,6 +130,8 @@ const translating = ref(false)
// return langs.filter((l: Language) => ['cs', 'pl', 'der'].includes(l.iso_code)) // return langs.filter((l: Language) => ['cs', 'pl', 'der'].includes(l.iso_code))
// }) // })
const isEditing = ref(false)
const availableLangs = computed(() => langs) const availableLangs = computed(() => langs)
const selectedLanguage = ref('pl') const selectedLanguage = ref('pl')
@@ -152,12 +166,50 @@ const usageRef = ref<HTMLElement | null>(null)
const descriptionEdit = useEditable(descriptionRef) const descriptionEdit = useEditable(descriptionRef)
const usageEdit = useEditable(usageRef) const usageEdit = useEditable(usageRef)
const save = async () => { const originalDescription = ref('')
const originalUsage = ref('')
const saveDescription = async () => {
descriptionEdit.disableEdit() descriptionEdit.disableEdit()
usageEdit.disableEdit()
await productStore.saveProductDescription() await productStore.saveProductDescription()
} }
const cancelDescriptionEdit = () => {
if (descriptionRef.value) {
descriptionRef.value.innerHTML = originalDescription.value
}
descriptionEdit.disableEdit()
}
const enableDescriptionEdit = () => {
if (descriptionRef.value) {
originalDescription.value = descriptionRef.value.innerHTML
}
descriptionEdit.enableEdit()
}
const enableEdit = () => {
if (usageRef.value) {
originalUsage.value = usageRef.value.innerHTML
}
isEditing.value = true
usageEdit.enableEdit()
}
const saveText = () => {
usageEdit.disableEdit()
isEditing.value = false
productStore.saveProductDescription()
}
const cancelEdit = () => {
if (usageRef.value) {
usageRef.value.innerHTML = originalUsage.value
}
usageEdit.disableEdit()
isEditing.value = false
}
</script> </script>
<style> <style>

View File

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

View File

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

View File

@@ -0,0 +1,164 @@
<template>
<div class="container mt-14 mx-auto">
<div class="flex justify-between gap-8 mb-6">
<div class="flex-1">
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-8 flex items-center justify-center min-h-[300px]">
<img :src="selectedColor?.image || productData.image" :alt="productData.name"
class="max-w-full h-auto object-contain" />
</div>
</div>
<div class="flex-1 flex flex-col gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ productData.name }}
</h1>
<p class="text-gray-600 dark:text-gray-300">
{{ productData.description }}
</p>
<div class="text-3xl font-bold text-(--accent-blue-light) dark:text-(--accent-blue-dark)">
{{ productData.price }}
</div>
<div class="flex flex-col">
<div class="flex gap-2">
<span class="text-gray-500 dark:text-gray-400">Dimensions:</span>
<p class="font-medium dark:text-white">{{ productData.dimensions }}</p>
</div>
<div class="flex gap-2">
<span class="text-gray-500 dark:text-gray-400">Seat Height:</span>
<p class="font-medium dark:text-white">{{ productData.seatHeight }}</p>
</div>
</div>
</div>
</div>
<div class="flex justify-between items-end mb-8">
<div class="flex flex-col gap-3">
<span class="text-sm text-(--accent-blue-light) dark:text-(--accent-blue-dark) ">Colors:</span>
<div class="flex gap-2">
<button v-for="color in productData.colors" :key="color.id" @click="selectedColor = color"
class="w-10 h-10 border-2 transition-all" :class="selectedColor?.id === color.id
? 'border-(--accent-blue-light) ring-2 ring-blue-600 ring-offset-2'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'"
:style="{ backgroundColor: color.hex }" :title="color.name" />
</div>
</div>
<div class="flex gap-5 items-end">
<UInputNumber v-model="value" />
<UButton color="primary" class="px-14! bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white">
Add to Cart
</UButton>
</div>
</div>
<ProductCustomization />
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
<div class="mb-6 w-[55%]">
<div class="flex justify-between items-center gap-10 mb-8">
<UButton v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
'px-15 py-2 cursor-pointer',
activeTab === tab.id
? 'bg-blue-600 hover:text-black hover:dark:text-white text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
]" variant="ghost">
{{ tab.label }}
</UButton>
</div>
<div class="py-5 px-3 bg-(--second-light) dark:bg-(--main-dark) rounded-md">
<p class="dark:text-white whitespace-pre-line">
{{ activeTabContent }}
</p>
</div>
</div>
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
<ProductVariants />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ProductCustomization from './components/ProductCustomization.vue'
import ProductVariants from './components/ProductVariants.vue'
interface Color {
id: string
name: string
hex: string
image: string
}
interface ProductData {
name: string
description: string
price: string
dimensions: string
seatHeight: string
image: string
colors: Color[]
descriptionText: string
howToUseText: string
productDetailsText: string
documentsText: string
}
const activeTab = ref('description')
const value = ref(5)
const selectedColor = ref<Color | null>(null)
const productData: ProductData = {
name: 'Larger Corner Sofa',
description: 'The large corner sofa is a comfortable seating solution for young children. It is upholstered with phthalate-free PVC-coated material intended for medical products, making it very easy to clean and disinfect. The product is available in a wide range of colours (13 colours), allowing it to fit into any interior.',
price: 'PLN 519.00 (VAT 23%)',
dimensions: '65 x 65 x 120 cm',
seatHeight: '45-55 cm',
image: '/placeholder-chair.jpg',
colors: [
{ id: 'black', name: 'Black', hex: '#1a1a1a', image: '/chair-black.jpg' },
{ id: 'gray', name: 'Gray', hex: '#6b7280', image: '/chair-gray.jpg' },
{ id: 'blue', name: 'Blue', hex: '#3b82f6', image: '/chair-blue.jpg' },
{ id: 'brown', name: 'Brown', hex: '#92400e', image: '/chair-brown.jpg' },
],
descriptionText: 'The large corner sofa is a comfortable seating solution for young children. It is upholstered with phthalate-free PVC-coated material intended for medical products, making it very easy to clean and disinfect. The product is available in a wide range of colours (13 colours), allowing it to fit into any interior',
howToUseText: '1. Adjust the seat height using the lever under the seat.\n2. Set the lumbar support to your preferred position.\n3. Adjust the armrests for optimal arm support.\n4. Use the recline tension knob to adjust the backrest resistance.\n5. Lock the recline position when needed.',
productDetailsText: '• Material: Mesh, Foam, Plastic\n• Max Load: 150 kg\n• Weight: 18 kg\n• Warranty: 2 years\n• Certifications: BIFMA, EN 1335',
documentsText: '• Assembly Instructions (PDF)\n• User Manual (PDF)\n• Warranty Terms (PDF)\n• Safety Certificate (PDF)',
}
const tabs = [
{ id: 'description', label: 'Description' },
{ id: 'howToUse', label: 'How to Use' },
{ id: 'productDetails', label: 'Product Details' },
{ id: 'documents', label: 'Documents' },
]
const userActions = [
'View detailed product information',
'Browse product images and available colors',
'Check product dimensions and specifications',
'Select a product variant',
'Select quantity',
'Add the product to the cart',
'Navigate between product description, usage instructions, and product details',
]
const activeTabContent = computed(() => {
switch (activeTab.value) {
case 'description':
return productData.descriptionText
case 'howToUse':
return productData.howToUseText
case 'productDetails':
return productData.productDetailsText
case 'documents':
return productData.documentsText
default:
return ''
}
})
if (productData.colors.length > 0) {
selectedColor.value = productData.colors[0] as Color
}
</script>
<style scoped>
.product-card-full {
font-family: inherit;
}
</style>

View File

@@ -0,0 +1,16 @@
<template>
<div class="container flex flex-col gap-8">
<div class="space-y-1 dark:text-white text-black">
<p class="text-[24px] font-bold">Product customization</p>
<p class="text-[15px]">Don't forget to save your customization to be able to add to cart</p>
</div>
<div class="grid grid-cols-3 gap-10">
<UInput label="Podaj kolor kanapy narożnej" placeholder="Podaj kolor kanapy narożnej" class="dark:text-white text-black"/>
<UInput label="Podaj kolor fotela" placeholder="Podaj kolor fotela" class="dark:text-white text-black"/>
<UInput label="Podaj kolor kwadratu" placeholder="Podaj kolor kwadratu" class="dark:text-white text-black"/>
</div>
<div class="flex justify-end items-end mb-8">
<UButton class="px-10! bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white">Save</UButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,57 @@
<template>
<div class="container flex flex-col gap-8">
<p class="text-[24px] font-bold dark:text-white text-black">Product Variants:</p>
<div class="flex flex-col gap-4">
<div v-for="(variant, index) in variants" :key="index" class="flex gap-10">
<div
class="flex items-center gap-15 border border-(--border-light) dark:border-(--border-dark) p-5 rounded-md hover:bg-gray-50 hover:dark:bg-gray-700 bg-(--second-light) dark:bg-(--second-dark) dark:text-white text-black">
<img :src="variant.image" :alt="variant.image" class="w-16 h-16 object-cover" />
<p class="">{{ variant.name }}</p>
<p class="">{{ variant.productNumber }}</p>
<p class="">{{ variant.value }}</p>
<p class="">{{ variant.price }}</p>
<p class="">{{ variant.quantity }}</p>
<button class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
@click="addToCart(variant)">
Add to Cart
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const variants = ref([
{
image: 'img',
name: 'Duży fotelik narożny ',
productNumber: 'NC209/7000',
value: '20,000',
price: '519,00 zł',
quantity: 10
},
{
image: 'img',
name: 'Duży fotelik narożny ',
productNumber: 'NC209/7000',
value: '20,000',
price: '519,00 zł',
quantity: 5
},
{
image: 'img',
name: 'Duży fotelik narożny ',
productNumber: 'NC209/7000',
value: '20,000',
price: '519,00 zł',
quantity: 8
}
])
const addToCart = (variant: any) => {
console.log('Added to cart:', variant)
}
</script>

View File

@@ -1,14 +1,56 @@
<script setup lang="ts"> <script setup lang="ts">
import TopBar from '@/components/TopBar.vue'; import TopBar from '@/components/TopBar.vue';
import { getMenu } from '@/router/menu'
import type { NavigationMenuItem } from '@nuxt/ui';
import { on } from 'events';
import { ref } from 'vue';
let menu = await getMenu() as NavigationMenuItem[]
function adaptMenu(menu) {
for (const item of menu) {
if (item.children && item.children.length > 0) {
adaptMenu(item.children);
// item.children.unshift({ label: item.label, icon: 'i-lucide-book-open', popover: item.label ,to: { name: 'category', params: item.params }})
// item.type = 'trigger'
// item.icon = 'i-lucide-book-open'
// item.onSelect(e){ console.log(e, 'adasdasdas'); }
} else {
// console.log(item.params);
item.to = { name: 'category', params: item.params };
item.icon = 'i-lucide-file-text'
}
}
return menu;
}
menu = adaptMenu(menu.children)
console.log(menu);
const items = ref<NavigationMenuItem[][]>([
[
...menu
],
])
</script> </script>
<template> <template>
<div class="h-screen grid grid-rows-[auto_1fr_auto]"> <div class="h-screen grid grid-rows-[auto_1fr_auto]">
<!-- <header class="w-full bg-gray-100 text-primary shadow border-b-gray-300 p-4 mb-8">Header</header> --> <main class="p-10">
<main class="p-10"> <div class="mt-24 bg-accented w-1/5 rounded-2xl">
<TopBar/> <UNavigationMenu orientation="vertical" type="single" :items="items" class="p-4">
<router-view /> </UNavigationMenu>
</main>
</div>
<!-- <template #item>{{ item }}</template> -->
<TopBar/>
<slot></slot>
</main>
</div> </div>
</template> </template>

View File

@@ -1,7 +1,7 @@
<template> <template>
<main :key="i18n.locale.value"> <main :key="i18n.locale.value">
<TopBarLogin /> <TopBarLogin />
<router-view /> <slot></slot>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,12 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import Empty from '@/layouts/empty.vue'
import { currentLang, langs } from './langs' import { currentLang, langs } from './langs'
import { getSettings } from './settings' import { getSettings } from './settings'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import Default from '@/layouts/default.vue' import Default from '@/layouts/default.vue'
import { getMenu } from './menu'
// Helper: read the non-HTTPOnly is_authenticated cookie set by the backend.
// The backend sets it to "1" on login and removes it on logout.
function isAuthenticated(): boolean { function isAuthenticated(): boolean {
if (typeof document === 'undefined') return false if (typeof document === 'undefined') return false
return document.cookie.split('; ').some((c) => c === 'is_authenticated=1') return document.cookie.split('; ').some((c) => c === 'is_authenticated=1')
@@ -14,6 +12,7 @@ function isAuthenticated(): boolean {
await getSettings() await getSettings()
// await getMenu()
const router = createRouter({ const router = createRouter({
@@ -26,64 +25,101 @@ const router = createRouter({
{ {
path: '/:locale', path: '/:locale',
children: [ children: [
{ path: 'category/:category_id-:link_rewrite', component: () => import('../views/CategoryView.vue'), name: 'category' },
{ {
path: '', path: '',
component: Default, component: Default,
children: [ children: [
{ path: '', component: () => import('../views/RepoChartView.vue'), name: 'home' }, { path: '', component: () => import('../views/RepoChartView.vue'), name: 'home' },
{ path: 'products', component: () => import('../views/customer/ProductsView.vue'), name: 'products' }, { path: 'products', component: () => import('../components/admin/ProductsView.vue'), name: 'products' },
{ path: 'products-datail/', component: () => import('../views/customer/ProductDetailView.vue'), name: 'product-detail' }, { path: 'products-datail/', component: () => import('../components/admin/ProductDetailView.vue'), name: 'product-detail' },
{ path: 'product-card-full/', component: () => import('../components/customer/PageProductCardFull.vue'), name: 'product-card-full' },
{ path: 'addresses', component: () => import('../components/customer/PageAddresses.vue'), name: 'addresses' },
{ path: 'cart', component: () => import('../components/customer/PageCart.vue'), name: 'cart' },
], ],
}, },
{ path: 'login', component: () => import('@/views/LoginView.vue'), name: 'login', meta: { guest: true, } },
{ path: 'register', component: () => import('@/views/RegisterView.vue'), name: 'register', meta: { guest: true } },
{ path: 'password-recovery', component: () => import('@/views/PasswordRecoveryView.vue'), name: 'password-recovery', meta: { guest: true } },
{ path: 'reset-password', component: () => import('@/views/ResetPasswordForm.vue'), name: 'reset-password', meta: { guest: true } },
{ path: 'verify-email', component: () => import('@/views/VerifyEmailView.vue'), name: 'verify-email', meta: { guest: true } },
// {
// path: '',
// component: Empty,
// children: [
// ],
// },
{ {
path: '', path: '/:pathMatch(.*)*',
component: Empty, component: () => import('@/views/NotFoundView.vue'),
children: [ name: 'not-found',
{ path: 'login', component: () => import('@/views/LoginView.vue'), name: 'login', meta: { guest: true } },
{ path: 'register', component: () => import('@/views/RegisterView.vue'), name: 'register', meta: { guest: true } },
{ path: 'password-recovery', component: () => import('@/views/PasswordRecoveryView.vue'), name: 'password-recovery', meta: { guest: true } },
{ path: 'reset-password', component: () => import('@/views/ResetPasswordForm.vue'), name: 'reset-password', meta: { guest: true } },
{ path: 'verify-email', component: () => import('@/views/VerifyEmailView.vue'), name: 'verify-email', meta: { guest: true } },
],
}, },
], ],
}, },
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
name: 'not-found',
},
], ],
}) })
// Navigation guard: language handling + auth protection // router.beforeEach((to, from, next) => {
router.beforeEach((to, from, next) => { // const locale = to.params.locale as string
const locale = to.params.locale as string // const localeLang = langs.find((x) => x.iso_code == locale)
const localeLang = langs.find((x) => x.iso_code == locale)
// if (locale && langs.length > 0) {
// const authStore = useAuthStore()
// // console.log(authStore.isAuthenticated, to, from)
// // if()
// const validLocale = langs.find((l) => l.lang_code === locale)
// if (validLocale) {
// currentLang.value = localeLang
// if (!to.meta?.guest && !isAuthenticated()) {
// return next({ name: 'login', params: { locale } })
// }
// // return next()
// return next()
// } else if (locale) {
// return next(`/${currentLang.value?.iso_code}${to.path.replace(`/${locale}`, '') || '/'}`)
// }
// }
// if (!locale && to.path !== '/') {
// return next(`/${currentLang.value?.iso_code}${to.path}`)
// }
// next()
// })
router.beforeEach((to, from) => {
const locale = to.params.locale as string
const localeLang = langs.find((x) => x.iso_code === locale)
// Check if the locale is valid
if (locale && langs.length > 0) { if (locale && langs.length > 0) {
const authStore = useAuthStore() const authStore = useAuthStore()
console.log(authStore.isAuthenticated,to, from)
// if()
const validLocale = langs.find((l) => l.lang_code === locale) const validLocale = langs.find((l) => l.lang_code === locale)
if (validLocale) { if (validLocale) {
currentLang.value = localeLang currentLang.value = localeLang
// Auth guard: if the route does NOT have meta.guest = true, require authentication if (!to.meta?.guest && !authStore.isAuthenticated) {
if (!to.meta?.guest && !isAuthenticated()) { return { name: 'login', params: { locale } }
return next({ name: 'login', params: { locale } })
} }
return next() return true
} else if (locale) { } else if (locale) {
// Invalid locale - redirect to default language return `/${currentLang.value?.iso_code}${to.path.replace(`/${locale}`, '') || '/'}`
return next(`/${currentLang.value?.iso_code}${to.path.replace(`/${locale}`, '') || '/'}`)
} }
} }
// No locale in URL - redirect to default language
if (!locale && to.path !== '/') { if (!locale && to.path !== '/') {
return next(`/${currentLang.value?.iso_code}${to.path}`) return `/${currentLang.value?.iso_code}${to.path}`
} }
next() return true
}) })
export default router export default router

8
bo/src/router/menu.ts Normal file
View File

@@ -0,0 +1,8 @@
import { useFetchJson } from "@/composable/useFetchJson";
export const getMenu = async () => {
const resp = await useFetchJson('/api/v1/restricted/menu/get-menu');
return resp.items
}

145
bo/src/stores/address.ts Normal file
View File

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

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

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

View File

@@ -0,0 +1,21 @@
<template>
<component :is="Default || 'div'">
<div class="container mt-24">
<div class="row">
<div class="col-12">
<h1>Category</h1>
{{ $route.params }}
</div>
</div>
</div>
</component>
</template>
<script setup lang="ts">
// import { useRoute } from 'vue-router';
import Default from '@/layouts/default.vue';
// const route = useRoute()
// console.log(route);
</script>

View File

@@ -4,8 +4,8 @@ import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useValidation } from '@/composable/useValidation' import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui' import type { FormError } from '@nuxt/ui'
import { useI18n } from 'vue-i18n'
import { i18n } from '@/plugins/02_i18n' import { i18n } from '@/plugins/02_i18n'
import Empty from '@/layouts/empty.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -52,9 +52,11 @@ const PrivacyComponent = computed(() =>
import(`@/components/terms/${i18n.locale.value}_PrivacyPolicyView.vue`).catch(() => import('@/components/terms/en_PrivacyPolicyView.vue')), import(`@/components/terms/${i18n.locale.value}_PrivacyPolicyView.vue`).catch(() => import('@/components/terms/en_PrivacyPolicyView.vue')),
), ),
) )
</script> </script>
<template> <template>
<component :is="Empty || 'div'">
<UDrawer v-model:open="showTherms" :overlay="false"> <UDrawer v-model:open="showTherms" :overlay="false">
<template #body> <template #body>
<component :is="TermsComponent" /> <component :is="TermsComponent" />
@@ -163,4 +165,5 @@ const PrivacyComponent = computed(() =>
</p> </p>
</div> </div>
</div> </div>
</component>
</template> </template>

View File

@@ -0,0 +1,7 @@
<template>
<div class="flex flex-col items-center justify-center h-screen">
<h1 class="text-4xl font-bold text-gray-800">404</h1>
<p class="mt-4 text-lg text-gray-600">Page not found</p>
<router-link to="/" class="mt-6 text-blue-500 hover:underline">Go back home</router-link>
</div>
</template>

View File

@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
import { useValidation } from '@/composable/useValidation' import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui' import type { FormError } from '@nuxt/ui'
import { i18n } from '@/plugins/02_i18n' import { i18n } from '@/plugins/02_i18n'
import Empty from '@/layouts/empty.vue'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -36,70 +37,74 @@ function validate(): FormError[] {
</script> </script>
<template> <template>
<div class="h-[100vh] flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8"> <component :is="Empty || 'div'">
<div class="text-center mb-15"> <div class="h-[100vh] flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
<div <div class="text-center mb-15">
class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30"> <div
<UIcon name="i-heroicons-clock" class="w-8 h-8" /> class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30">
<UIcon name="i-heroicons-clock" class="w-8 h-8" />
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">TimeTracker</h1>
</div> </div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">TimeTracker</h1> <div class="w-full max-w-md flex flex-col gap-4">
</div>
<div class="w-full max-w-md flex flex-col gap-4">
<template v-if="submitted"> <template v-if="submitted">
<div class="text-center flex flex-col gap-4"> <div class="text-center flex flex-col gap-4">
<UIcon name="i-heroicons-envelope" class="w-12 h-12 mx-auto text-primary-500" /> <UIcon name="i-heroicons-envelope" class="w-12 h-12 mx-auto text-primary-500" />
<h2 class="text-xl font-semibold dark:text-white text-black">{{ $t('general.check_your_email') }}</h2> <h2 class="text-xl font-semibold dark:text-white text-black">{{ $t('general.check_your_email') }}</h2>
<p class="text-sm text-gray-600 dark:text-gray-400"> <p class="text-sm text-gray-600 dark:text-gray-400">
{{ $t('general.password_reset_link_sent_notice') }} {{ $t('general.password_reset_link_sent_notice') }}
</p> </p>
<UButton color="neutral" variant="outline" block @click="goToLogin" <UButton color="neutral" variant="outline" block @click="goToLogin"
class="dark:text-white text-black cursor-pointer"> class="dark:text-white text-black cursor-pointer">
{{ $t('general.back_to_sign_in') }} {{ $t('general.back_to_sign_in') }}
</UButton> </UButton>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="text-center"> <div class="text-center">
<p class="text-sm text-gray-600 dark:text-gray-400"> <p class="text-sm text-gray-600 dark:text-gray-400">
{{ $t('general.enter_email_for_password_reset') }} {{ $t('general.enter_email_for_password_reset') }}
</p> </p>
</div> </div>
<UForm :validate="validate" @submit="handleRecover" class="flex flex-col gap-3"> <UForm :validate="validate" @submit="handleRecover" class="flex flex-col gap-3">
<UAlert v-if="authStore.error" color="error" variant="subtle" icon="i-heroicons-exclamation-triangle" <UAlert v-if="authStore.error" color="error" variant="subtle" icon="i-heroicons-exclamation-triangle"
:title="authStore.error" :close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }" :title="authStore.error" :close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }"
@close="authStore.clearError" /> @close="authStore.clearError" />
<UFormField :label="$t('general.email_address')" name="email" required <UFormField :label="$t('general.email_address')" name="email" required
class="w-full dark:text-white text-black"> class="w-full dark:text-white text-black">
<UInput v-model="email" :placeholder="$t('general.enter_your_email')" :disabled="authStore.loading" <UInput v-model="email" :placeholder="$t('general.enter_your_email')" :disabled="authStore.loading"
class="w-full dark:text-white text-black placeholder:text-(--placeholder)" /> class="w-full dark:text-white text-black placeholder:text-(--placeholder)" />
</UFormField> </UFormField>
<UButton type="submit" block :loading="authStore.loading" <UButton type="submit" block :loading="authStore.loading"
class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) cursor-pointer"> class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) cursor-pointer">
{{ $t('general.send_password_reset_link') }} {{ $t('general.send_password_reset_link') }}
</UButton> </UButton>
</UForm> </UForm>
<div class="text-center flex flex-col gap-3 border-t dark:border-(--border-dark) border-(--border-light) pt-4"> <div
<button color="neutral" variant="outline" :loading="authStore.loading" class="text-center flex flex-col gap-3 border-t dark:border-(--border-dark) border-(--border-light) pt-4">
class="w-full flex items-center gap-2 justify-center text-[15px] dark:text-white text-black cursor-pointer" <button color="neutral" variant="outline" :loading="authStore.loading"
@click="goToLogin"> class="w-full flex items-center gap-2 justify-center text-[15px] dark:text-white text-black cursor-pointer"
<UIcon name="mingcute:arrow-left-line" class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) text-[16px]" /> @click="goToLogin">
{{ $t('general.back_to_sign_in') }} <UIcon name="mingcute:arrow-left-line"
</button> class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) text-[16px]" />
<p class="text-sm text-gray-600 dark:text-gray-400"> {{ $t('general.back_to_sign_in') }}
{{ $t('general.dont_have_an_account') }}
<button variant="link" size="sm" @click="goToRegister"
class=" text-[15px] text-(--accent-blue-light) dark:text-(--accent-blue-dark) cursor-pointer">{{
$t('general.create_account_now') }}
</button> </button>
</p> <p class="text-sm text-gray-600 dark:text-gray-400">
</div> {{ $t('general.dont_have_an_account') }}
</template> <button variant="link" size="sm" @click="goToRegister"
class=" text-[15px] text-(--accent-blue-light) dark:text-(--accent-blue-dark) cursor-pointer">{{
$t('general.create_account_now') }}
</button>
</p>
</div>
</template>
</div>
</div> </div>
</div> </component>
</template> </template>

View File

@@ -1,4 +1,5 @@
<template> <template>
<component :is="Empty || 'div'">
<UDrawer v-model:open="showTherms" :overlay="false"> <UDrawer v-model:open="showTherms" :overlay="false">
<template #body> <template #body>
<component :is="TermsComponent" /> <component :is="TermsComponent" />
@@ -110,6 +111,7 @@
</UForm> </UForm>
</div> </div>
</div> </div>
</component>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -120,6 +122,8 @@ import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui' import type { FormError } from '@nuxt/ui'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { i18n } from '@/plugins/02_i18n' import { i18n } from '@/plugins/02_i18n'
import Empty from '@/layouts/empty.vue'
const { locale } = useI18n() const { locale } = useI18n()
const router = useRouter() const router = useRouter()

View File

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

View File

@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
import { useValidation } from '@/composable/useValidation' import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui' import type { FormError } from '@nuxt/ui'
import { i18n } from '@/plugins/02_i18n' import { i18n } from '@/plugins/02_i18n'
import Empty from '@/layouts/empty.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -49,80 +50,84 @@ function validate(): FormError[] {
</script> </script>
<template> <template>
<div class="h-[100vh] flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8"> <component :is="Empty || 'div'">
<div class="text-center mb-15"> <div class="h-[100vh] flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
<div <div class="text-center mb-15">
class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30"> <div
<UIcon name="i-heroicons-clock" class="w-8 h-8" /> class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30">
</div> <UIcon name="i-heroicons-clock" class="w-8 h-8" />
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">TimeTracker</h1>
</div>
<div class="w-full max-w-md flex flex-col gap-4">
<template v-if="submitted">
<div class="text-center flex flex-col gap-4">
<UIcon name="i-heroicons-check-circle" class="w-12 h-12 mx-auto text-green-800" />
<h2 class="text-xl font-semibold dark:text-white text-black">
{{ $t('general.password_updated') }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ $t('general.password_updated_description') }}
</p>
<UButton block @click="goToLogin" class="dark:text-white text-black">
{{ $t('general.back_to_sign_in') }}
</UButton>
</div> </div>
</template> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">TimeTracker</h1>
</div>
<div class="w-full max-w-md flex flex-col gap-4">
<template v-else> <template v-if="submitted">
<UForm :validate="validate" @submit="handleReset" class="flex flex-col gap-3"> <div class="text-center flex flex-col gap-4">
<UAlert v-if="authStore.error" color="error" variant="subtle" <UIcon name="i-heroicons-check-circle" class="w-12 h-12 mx-auto text-green-800" />
icon="i-heroicons-exclamation-triangle" :title="authStore.error" <h2 class="text-xl font-semibold dark:text-white text-black">
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }" {{ $t('general.password_updated') }}
@close="authStore.clearError" /> </h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
<UFormField :label="$t('general.new_password')" name="new_password" required {{ $t('general.password_updated_description') }}
class="w-full dark:text-white text-black"> </p>
<UInput v-model="new_password" :type="showNewPassword ? 'text' : 'password'" <UButton block @click="goToLogin" class="dark:text-white text-black">
:placeholder="$t('general.enter_your_new_password')" :disabled="authStore.loading"
class="w-full dark:text-white text-black placeholder:text-(--placeholder)" :ui="{ trailing: 'pe-1' }">
<template #trailing>
<UIcon color="neutral" variant="link" size="sm"
:name="showNewPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="showNewPassword ? 'Hide password' : 'Show password'"
:aria-pressed="showNewPassword" aria-controls="new_password"
@click="showNewPassword = !showNewPassword" class="mr-2"/>
</template>
</UInput>
</UFormField>
<UFormField :label="$t('general.confirm_password')" name="confirm_new_password" required
class="w-full dark:text-white text-black">
<UInput v-model="confirm_new_password" :type="showConfirmPassword ? 'text' : 'password'"
:placeholder="$t('general.confirm_your_new_password')" :disabled="authStore.loading"
class="w-full dark:text-white text-black placeholder:text-(--placeholder)" :ui="{ trailing: 'pe-1' }">
<template #trailing>
<UIcon color="neutral" variant="ghost" size="sm"
:name="showConfirmPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
@click="showConfirmPassword = !showConfirmPassword" class="mr-2"/>
</template>
</UInput>
</UFormField>
<UButton type="submit" block :loading="authStore.loading"
class="text-white! bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) cursor-pointer">
{{ $t('general.reset_password') }}
</UButton>
<div class="text-center border-t dark:border-(--border-dark) border-(--border-light) pt-4">
<button color="neutral" variant="ghost" @click="goToLogin"
class="text-[15px] flex items-center gap-2 text-(--accent-blue-light) dark:text-(--accent-blue-dark) cursor-pointer">
<UIcon name="mingcute:arrow-left-line" />
{{ $t('general.back_to_sign_in') }} {{ $t('general.back_to_sign_in') }}
</button> </UButton>
</div> </div>
</UForm> </template>
</template>
<template v-else>
<UForm :validate="validate" @submit="handleReset" class="flex flex-col gap-3">
<UAlert v-if="authStore.error" color="error" variant="subtle"
icon="i-heroicons-exclamation-triangle" :title="authStore.error"
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }"
@close="authStore.clearError" />
<UFormField :label="$t('general.new_password')" name="new_password" required
class="w-full dark:text-white text-black">
<UInput v-model="new_password" :type="showNewPassword ? 'text' : 'password'"
:placeholder="$t('general.enter_your_new_password')" :disabled="authStore.loading"
class="w-full dark:text-white text-black placeholder:text-(--placeholder)"
:ui="{ trailing: 'pe-1' }">
<template #trailing>
<UIcon color="neutral" variant="link" size="sm"
:name="showNewPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="showNewPassword ? 'Hide password' : 'Show password'"
:aria-pressed="showNewPassword" aria-controls="new_password"
@click="showNewPassword = !showNewPassword" class="mr-2" />
</template>
</UInput>
</UFormField>
<UFormField :label="$t('general.confirm_password')" name="confirm_new_password" required
class="w-full dark:text-white text-black">
<UInput v-model="confirm_new_password" :type="showConfirmPassword ? 'text' : 'password'"
:placeholder="$t('general.confirm_your_new_password')" :disabled="authStore.loading"
class="w-full dark:text-white text-black placeholder:text-(--placeholder)"
:ui="{ trailing: 'pe-1' }">
<template #trailing>
<UIcon color="neutral" variant="ghost" size="sm"
:name="showConfirmPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
@click="showConfirmPassword = !showConfirmPassword" class="mr-2" />
</template>
</UInput>
</UFormField>
<UButton type="submit" block :loading="authStore.loading"
class="text-white! bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) cursor-pointer">
{{ $t('general.reset_password') }}
</UButton>
<div class="text-center border-t dark:border-(--border-dark) border-(--border-light) pt-4">
<button color="neutral" variant="ghost" @click="goToLogin"
class="text-[15px] flex items-center gap-2 text-(--accent-blue-light) dark:text-(--accent-blue-dark) cursor-pointer">
<UIcon name="mingcute:arrow-left-line" />
{{ $t('general.back_to_sign_in') }}
</button>
</div>
</UForm>
</template>
</div>
</div> </div>
</div> </component>
</template> </template>

View File

@@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useFetchJson } from '@/composable/useFetchJson' import { useFetchJson } from '@/composable/useFetchJson'
import { i18n } from '@/plugins/02_i18n' import { i18n } from '@/plugins/02_i18n'
import Empty from '@/layouts/empty.vue'
const { t, te } = useI18n() const { t, te } = useI18n()
const router = useRouter() const router = useRouter()
@@ -66,81 +67,84 @@ function goToLogin() {
</script> </script>
<template> <template>
<div <component :is="Empty || 'div'">
class="min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900"> <div
<div class="pt-20 pb-8 flex items-center justify-center px-4 sm:px-6 lg:px-8"> class="min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<div class="w-full max-w-md"> <div class="pt-20 pb-8 flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="text-center mb-8"> <div class="w-full max-w-md">
<div <div class="text-center mb-8">
class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30"> <div
<UIcon name="i-heroicons-clock" class="w-8 h-8" /> class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30">
<UIcon name="i-heroicons-clock" class="w-8 h-8" />
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">TimeTracker</h1>
</div> </div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">TimeTracker</h1>
<UCard class="shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50">
<template #header>
<div class="text-center">
<div v-if="verificationInProgress && loading">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-primary-500 mx-auto mb-4" />
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ $t('verify_email.verifying') }}
</h2>
</div>
<div v-else-if="success">
<div
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-100 text-green-600 mb-4">
<UIcon name="i-heroicons-check-circle" class="w-6 h-6" />
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ $t('verify_email.success_title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ $t('verify_email.success_message') }}
</p>
</div>
<div v-else-if="error">
<div
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-red-100 text-red-600 mb-4">
<UIcon name="i-heroicons-exclamation-circle" class="w-6 h-6" />
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ $t('verify_email.error_title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ $t('verify_email.error_message') }}
</p>
</div>
</div>
</template>
<div v-if="success" class="text-center py-4">
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ $t('verify_email.redirect_message') }}</p>
<UButton color="primary" @click="goToLogin">{{ $t('verify_email.go_to_login') }}</UButton>
</div>
<div v-else-if="error" class="text-center py-4">
<UAlert :color="'error'" variant="subtle" icon="i-heroicons-exclamation-triangle" :title="error"
class="mb-4" />
<UButton color="primary" @click="goToLogin" class="cursor-pointer">{{ $t('verify_email.go_to_login') }}
</UButton>
</div>
<div v-else-if="verificationInProgress && loading" class="text-center py-4">
<p class="text-gray-500 dark:text-gray-400">{{ $t('verify_email.please_wait') }}</p>
</div>
<template #footer>
<div class="text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ $t('verify_email.already_registered') }}
<button variant="link" size="sm" @click="goToLogin"
class="cursor-pointer text-(--accent-blue-light) dark:text-(--accent-blue-dark)"> {{
$t('general.sign_in')
}}
</button>
</p>
</div>
</template>
</UCard>
</div> </div>
<UCard class="shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50">
<template #header>
<div class="text-center">
<div v-if="verificationInProgress && loading">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-primary-500 mx-auto mb-4" />
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ $t('verify_email.verifying') }}
</h2>
</div>
<div v-else-if="success">
<div
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-100 text-green-600 mb-4">
<UIcon name="i-heroicons-check-circle" class="w-6 h-6" />
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ $t('verify_email.success_title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ $t('verify_email.success_message') }}
</p>
</div>
<div v-else-if="error">
<div
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-red-100 text-red-600 mb-4">
<UIcon name="i-heroicons-exclamation-circle" class="w-6 h-6" />
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ $t('verify_email.error_title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ $t('verify_email.error_message') }}
</p>
</div>
</div>
</template>
<div v-if="success" class="text-center py-4">
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ $t('verify_email.redirect_message') }}</p>
<UButton color="primary" @click="goToLogin">{{ $t('verify_email.go_to_login') }}</UButton>
</div>
<div v-else-if="error" class="text-center py-4">
<UAlert :color="'error'" variant="subtle" icon="i-heroicons-exclamation-triangle" :title="error"
class="mb-4" />
<UButton color="primary" @click="goToLogin" class="cursor-pointer">{{ $t('verify_email.go_to_login') }}
</UButton>
</div>
<div v-else-if="verificationInProgress && loading" class="text-center py-4">
<p class="text-gray-500 dark:text-gray-400">{{ $t('verify_email.please_wait') }}</p>
</div>
<template #footer>
<div class="text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ $t('verify_email.already_registered') }}
<button variant="link" size="sm" @click="goToLogin"
class="cursor-pointer text-(--accent-blue-light) dark:text-(--accent-blue-dark)"> {{ $t('general.sign_in')
}}
</button>
</p>
</div>
</template>
</UCard>
</div> </div>
</div> </div>
</div> </component>
</template> </template>

2
go.mod
View File

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

4
go.sum
View File

@@ -109,6 +109,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M=
github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/openai/openai-go/v3 v3.28.0 h1:2+FfrCVMdGXSQrBv1tLWtokm+BU7+3hJ/8rAHPQ63KM= github.com/openai/openai-go/v3 v3.28.0 h1:2+FfrCVMdGXSQrBv1tLWtokm+BU7+3hJ/8rAHPQ63KM=
@@ -124,6 +126,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=

View File

@@ -6,16 +6,16 @@ CREATE TABLE IF NOT EXISTS b2b_tracker_routes (
path VARCHAR(255) NULL, path VARCHAR(255) NULL,
component VARCHAR(255) NOT NULL COMMENT 'path to component file', component VARCHAR(255) NOT NULL COMMENT 'path to component file',
layout VARCHAR(50) DEFAULT 'default' COMMENT "'default' | 'empty'", layout VARCHAR(50) DEFAULT 'default' COMMENT "'default' | 'empty'",
meta JSON DEFAULT '{}' , meta JSON DEFAULT '{}',
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0, sort_order INT DEFAULT 0,
parent_id INT NULL parent_id INT NULL,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE b2b_tracker_routes CONSTRAINT fk_parent
ADD CONSTRAINT fk_parent FOREIGN KEY (parent_id)
FOREIGN KEY (parent_id) REFERENCES b2b_tracker_routes(id) REFERENCES b2b_tracker_routes(id)
ON DELETE SET NULL; ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO b2b_tracker_routes INSERT IGNORE INTO b2b_tracker_routes
(name, path, component, layout, meta, is_active, sort_order, parent_id) (name, path, component, layout, meta, is_active, sort_order, parent_id)

View File

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

431
package-lock.json generated
View File

@@ -4,6 +4,7 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "b2b",
"dependencies": { "dependencies": {
"@nuxt/ui": "^4.5.1", "@nuxt/ui": "^4.5.1",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@@ -1006,6 +1007,395 @@
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -1975,13 +2365,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@unhead/vue": { "node_modules/@unhead/vue": {
"version": "2.1.10", "version": "2.1.12",
"resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.10.tgz", "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.12.tgz",
"integrity": "sha512-VP78Onh2HNezLPfhYjfHqn4dxlcQsE6PJgTTs61NksO/thvilNswtgBq0N0MWCLtn43N5akEPGW2y2zxM3PWgQ==", "integrity": "sha512-zEWqg0nZM8acpuTZE40wkeUl8AhIe0tU0OkilVi1D4fmVjACrwoh5HP6aNqJ8kUnKsoy6D+R3Vi/O+fmdNGO7g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"hookable": "^6.0.1", "hookable": "^6.0.1",
"unhead": "2.1.10" "unhead": "2.1.12"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/harlan-zw" "url": "https://github.com/sponsors/harlan-zw"
@@ -2801,6 +3191,21 @@
} }
} }
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/fuse.js": { "node_modules/fuse.js": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
@@ -2846,9 +3251,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/h3": { "node_modules/h3": {
"version": "1.15.5", "version": "1.15.8",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.5.tgz", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.8.tgz",
"integrity": "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==", "integrity": "sha512-iOH6Vl8mGd9nNfu9C0IZ+GuOAfJHcyf3VriQxWaSWIB76Fg4BnFuk4cxBxjmQSSxJS664+pgjP6e7VBnUzFfcg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie-es": "^1.2.2", "cookie-es": "^1.2.2",
@@ -4257,9 +4662,9 @@
} }
}, },
"node_modules/unhead": { "node_modules/unhead": {
"version": "2.1.10", "version": "2.1.12",
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.10.tgz", "resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz",
"integrity": "sha512-We8l9uNF8zz6U8lfQaVG70+R/QBfQx1oPIgXin4BtZnK2IQpz6yazQ0qjMNVBDw2ADgF2ea58BtvSK+XX5AS7g==", "integrity": "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"hookable": "^6.0.1" "hookable": "^6.0.1"
@@ -4269,9 +4674,9 @@
} }
}, },
"node_modules/unhead/node_modules/hookable": { "node_modules/unhead/node_modules/hookable": {
"version": "6.0.1", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-6.0.1.tgz", "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz",
"integrity": "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==", "integrity": "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unifont": { "node_modules/unifont": {