Compare commits
29 Commits
e30088209e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc488bad8 | ||
|
|
f5f23f8a27 | ||
| cffe4c2f83 | |||
| 15e8626280 | |||
|
|
528f12b065 | ||
|
|
25ad592be3 | ||
|
|
26e6a3c384 | ||
| a4c1773415 | |||
| 8e07daac66 | |||
| 6408b93e5c | |||
| 27fa88b076 | |||
|
|
b67c4e3aef | ||
|
|
0d29d8f6a2 | ||
|
|
884e15bb8a | ||
|
|
1ea50af96a | ||
|
|
b6bf6ed5c6 | ||
| 7a66d6f429 | |||
| 43f856ee8d | |||
| 506c64e240 | |||
| 718b4d23f1 | |||
| 22e8556c9d | |||
|
|
52c17d7017 | ||
|
|
e094865fc7 | ||
|
|
01c8f4333f | ||
|
|
6cebcacb5d | ||
| c79e08dbb8 | |||
| 789d59b0c9 | |||
| 7388d0f828 | |||
|
|
a0dcb56fda |
8
.env
8
.env
@@ -21,6 +21,13 @@ AUTH_JWT_SECRET=5c020e6ed3d8d6e67e5804d67c83c4bd5ae474df749af6d63d8f20e7e2ba29b3
|
||||
AUTH_JWT_EXPIRATION=86400
|
||||
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_APPLICATION_CREDENTIALS=./google-cred.json
|
||||
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_SECRET=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
|
||||
OAUTH_GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/public/auth/google/callback
|
||||
|
||||
# Email Configuration (SMTP)
|
||||
# Set EMAIL_ENABLED=true to require email verification
|
||||
EMAIL_ENABLED=true
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ bin/
|
||||
i18n/*.json
|
||||
*_templ.go
|
||||
tmp/main
|
||||
test.go
|
||||
2995
ADD_THIS_TO_SQL.sql
Normal file
2995
ADD_THIS_TO_SQL.sql
Normal file
File diff suppressed because it is too large
Load Diff
12
Taskfile.yml
12
Taskfile.yml
@@ -73,10 +73,22 @@ vars:
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: true
|
||||
MP_ENABLE_SPAMASSASSIN: postmark
|
||||
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:
|
||||
db_data:
|
||||
mailpit_data:
|
||||
meilisearch:
|
||||
|
||||
|
||||
includes:
|
||||
|
||||
@@ -40,6 +40,7 @@ func AuthHandlerRoutes(r fiber.Router) fiber.Router {
|
||||
r.Post("/reset-password", handler.ResetPassword)
|
||||
r.Post("/logout", handler.Logout)
|
||||
r.Post("/refresh", handler.RefreshToken)
|
||||
r.Post("/update-choice", handler.UpdateJWTToken)
|
||||
|
||||
// Google OAuth2
|
||||
r.Get("/google", handler.GoogleLogin)
|
||||
@@ -344,6 +345,11 @@ func (h *AuthHandler) CompleteRegistration(c fiber.Ctx) error {
|
||||
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
|
||||
func (h *AuthHandler) GoogleLogin(c fiber.Ctx) error {
|
||||
// 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.
|
||||
// The user's preferred language is stored in the auth response; fall back to "en".
|
||||
lang := response.User.Lang
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
lang, err := h.authService.GetLangISOCode(response.User.LangID)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
126
app/delivery/web/api/restricted/listProducts.go
Normal file
126
app/delivery/web/api/restricted/listProducts.go
Normal 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)
|
||||
}
|
||||
52
app/delivery/web/api/restricted/localeSelector.go
Normal file
52
app/delivery/web/api/restricted/localeSelector.go
Normal 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)))
|
||||
}
|
||||
63
app/delivery/web/api/restricted/meiliSearch.go
Normal file
63
app/delivery/web/api/restricted/meiliSearch.go
Normal 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(¬hing, 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)))
|
||||
}
|
||||
45
app/delivery/web/api/restricted/menu.go
Normal file
45
app/delivery/web/api/restricted/menu.go
Normal 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)))
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"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/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"
|
||||
@@ -41,151 +43,110 @@ func ProductDescriptionHandlerRoutes(r fiber.Router) fiber.Router {
|
||||
func (h *ProductDescriptionHandler) GetProductDescription(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), // possibly could return a different error
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).
|
||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
|
||||
}
|
||||
|
||||
productID_attribute := c.Query("productID")
|
||||
productID, err := strconv.Atoi(productID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{
|
||||
"error": 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),
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
|
||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
|
||||
}
|
||||
|
||||
productLangID_attribute := c.Query("productLangID")
|
||||
productLangID, err := strconv.Atoi(productLangID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute),
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(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 {
|
||||
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, err),
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(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 {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), // possibly could return a different error
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).
|
||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
|
||||
}
|
||||
|
||||
productID_attribute := c.Query("productID")
|
||||
productID, err := strconv.Atoi(productID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{
|
||||
"error": 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),
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
|
||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
|
||||
}
|
||||
|
||||
productLangID_attribute := c.Query("productLangID")
|
||||
productLangID, err := strconv.Atoi(productLangID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute),
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
|
||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
|
||||
}
|
||||
|
||||
updates := make(map[string]string)
|
||||
if err := c.Bind().Body(&updates); err != nil {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(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 {
|
||||
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, err),
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(err)).
|
||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"message": i18n.T_(c, "product_description.successfully_updated_fields"),
|
||||
})
|
||||
return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK)))
|
||||
}
|
||||
|
||||
// GetProductDescription returns the product description for a given product ID
|
||||
// TranslateProductDescription returns translated product description
|
||||
func (h *ProductDescriptionHandler) TranslateProductDescription(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), // possibly could return a different error
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).
|
||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody)))
|
||||
}
|
||||
|
||||
productID_attribute := c.Query("productID")
|
||||
productID, err := strconv.Atoi(productID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{
|
||||
"error": 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),
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
|
||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
|
||||
}
|
||||
|
||||
productFromLangID_attribute := c.Query("productFromLangID")
|
||||
productFromLangID, err := strconv.Atoi(productFromLangID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute),
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
|
||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
|
||||
}
|
||||
|
||||
productToLangID_attribute := c.Query("productToLangID")
|
||||
productToLangID, err := strconv.Atoi(productToLangID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute),
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
|
||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
|
||||
}
|
||||
|
||||
model := c.Query("model")
|
||||
if model != "OpenAI" && model != "Google" {
|
||||
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute),
|
||||
})
|
||||
aiModel := c.Query("model")
|
||||
if aiModel != "OpenAI" && aiModel != "Google" {
|
||||
return c.Status(responseErrors.GetErrorStatus(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 {
|
||||
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": responseErrors.GetErrorCode(c, err),
|
||||
})
|
||||
return c.Status(responseErrors.GetErrorStatus(err)).
|
||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
return c.JSON(response.Make(description, 1, i18n.T_(c, response.Message_OK)))
|
||||
}
|
||||
|
||||
@@ -86,6 +86,6 @@ func (h *SettingsHandler) GetSettings(cfg *config.Config) fiber.Handler {
|
||||
Version: version.GetInfo(),
|
||||
}
|
||||
|
||||
return c.JSON(response.Make(c, fiber.StatusOK, nullable.GetNil(settings), nullable.GetNil(0), i18n.T_(c, response.Message_OK)))
|
||||
return c.JSON(response.Make(nullable.GetNil(settings), 0, i18n.T_(c, response.Message_OK)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +89,27 @@ func (s *Server) Setup() error {
|
||||
auth := s.public.Group("/auth")
|
||||
public.AuthHandlerRoutes(auth)
|
||||
|
||||
// Repo routes (restricted)
|
||||
// product description routes (restricted)
|
||||
productDescription := s.restricted.Group("/product-description")
|
||||
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 := s.api.Group("/restricted")
|
||||
// restricted.Use(middleware.AuthMiddleware())
|
||||
|
||||
11
app/model/countries.go
Normal file
11
app/model/countries.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
// Represents a country together with its associated currency
|
||||
type Country struct {
|
||||
ID uint `gorm:"primaryKey;column:id" json:"id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Flag string `gorm:"size:16;not null;column:flag" json:"flag"`
|
||||
CurrencyID uint `gorm:"column:id_currency" json:"currency_id"`
|
||||
CurrencyISOCode string `gorm:"column:iso_code" json:"currency_iso_code"`
|
||||
CurrencyName string `gorm:"column:name" json:"currency_name"`
|
||||
}
|
||||
@@ -25,7 +25,8 @@ type Customer struct {
|
||||
PasswordResetExpires *time.Time `json:"-"`
|
||||
LastPasswordResetRequest *time.Time `json:"-"`
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
@@ -76,9 +77,8 @@ type UserSession struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Role CustomerRole `json:"role"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Lang string `json:"lang"`
|
||||
LangID uint `json:"lang_id"`
|
||||
CountryID uint `json:"country_id"`
|
||||
}
|
||||
|
||||
// ToSession converts User to UserSession
|
||||
@@ -87,9 +87,8 @@ func (u *Customer) ToSession() *UserSession {
|
||||
UserID: u.ID,
|
||||
Email: u.Email,
|
||||
Role: u.Role,
|
||||
FirstName: u.FirstName,
|
||||
LastName: u.LastName,
|
||||
Lang: u.Lang,
|
||||
LangID: u.LangID,
|
||||
CountryID: u.CountryID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +106,8 @@ type RegisterRequest struct {
|
||||
ConfirmPassword string `json:"confirm_password" form:"confirm_password"`
|
||||
FirstName string `json:"first_name" form:"first_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
|
||||
|
||||
108
app/model/product.go
Normal file
108
app/model/product.go
Normal 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
|
||||
@@ -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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
41
app/repos/categoriesRepo/categoriesRepo.go
Normal file
41
app/repos/categoriesRepo/categoriesRepo.go
Normal 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
|
||||
}
|
||||
82
app/repos/listProductsRepo/listProductsRepo.go
Normal file
82
app/repos/listProductsRepo/listProductsRepo.go
Normal 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
|
||||
}
|
||||
36
app/repos/localeSelectorRepo/localeSelectorRepo.go
Normal file
36
app/repos/localeSelectorRepo/localeSelectorRepo.go
Normal 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
|
||||
}
|
||||
108
app/repos/productDescriptionRepo/productDescriptionRepo.go
Normal file
108
app/repos/productDescriptionRepo/productDescriptionRepo.go
Normal 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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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/service/emailService"
|
||||
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"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
@@ -27,8 +32,9 @@ type JWTClaims struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Role model.CustomerRole `json:"customer_role"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
CartsIDs []uint `json:"carts_ids"`
|
||||
LangID uint `json:"lang_id"`
|
||||
CountryID uint `json:"country_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -149,7 +155,8 @@ func (s *AuthService) Register(req *model.RegisterRequest) error {
|
||||
EmailVerified: false,
|
||||
EmailVerificationToken: token,
|
||||
EmailVerificationExpires: &expiresAt,
|
||||
Lang: req.Lang,
|
||||
LangID: req.LangID,
|
||||
CountryID: req.CountryID,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
@@ -158,10 +165,11 @@ func (s *AuthService) Register(req *model.RegisterRequest) error {
|
||||
|
||||
// Send verification email
|
||||
baseURL := config.Get().App.BaseURL
|
||||
lang := req.Lang
|
||||
if lang == "" {
|
||||
lang = "en" // Default to English
|
||||
lang, err := s.GetLangISOCode(req.LangID)
|
||||
if err != nil {
|
||||
return responseErrors.ErrBadLangID
|
||||
}
|
||||
|
||||
if err := s.email.SendVerificationEmail(user.Email, user.EmailVerificationToken, baseURL, lang); err != nil {
|
||||
// Log error but don't fail registration - user can request resend
|
||||
_ = err
|
||||
@@ -266,10 +274,11 @@ func (s *AuthService) RequestPasswordReset(emailAddr string) error {
|
||||
|
||||
// Send password reset email
|
||||
baseURL := config.Get().App.BaseURL
|
||||
lang := "en"
|
||||
if user.Lang != "" {
|
||||
lang = user.Lang
|
||||
lang, err := s.GetLangISOCode(user.LangID)
|
||||
if err != nil {
|
||||
return responseErrors.ErrBadLangID
|
||||
}
|
||||
|
||||
if err := s.email.SendPasswordResetEmail(user.Email, user.PasswordResetToken, baseURL, lang); err != nil {
|
||||
_ = err
|
||||
}
|
||||
@@ -471,13 +480,24 @@ func hashToken(raw string) string {
|
||||
|
||||
// generateAccessToken generates a short-lived JWT access token
|
||||
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{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Email,
|
||||
Role: user.Role,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
CartsIDs: []uint{},
|
||||
LangID: user.LangID,
|
||||
CountryID: user.CountryID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.JWTExpiration) * time.Second)),
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
func (s *AuthService) generateVerificationToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
@@ -507,3 +605,29 @@ func validatePassword(password string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GetLangISOCode(langID uint) (string, error) {
|
||||
var lang string
|
||||
|
||||
if langID == 0 { // retrieve the default lang
|
||||
err := db.DB.Table("b2b_language").Where("is_default = ?", 1).Select("iso_code").Scan(&lang).Error
|
||||
return lang, err
|
||||
} else {
|
||||
err := db.DB.Table("b2b_language").Where("id = ?", langID).Where("active = ?", 1).Select("iso_code").Scan(&lang).Error
|
||||
return lang, err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) CheckIfCountryExists(countryID uint) error {
|
||||
var count int64
|
||||
|
||||
err := db.DB.Table("b2b_countries").Where("id = ?", countryID).Count(&count).Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return responseErrors.ErrBadCountryID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model.
|
||||
Role: model.RoleUser,
|
||||
IsActive: true,
|
||||
EmailVerified: true,
|
||||
Lang: "en",
|
||||
LangID: 2,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&newUser).Error; err != nil {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package langsService
|
||||
|
||||
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/nullable"
|
||||
"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] {
|
||||
res, err := s.repo.GetActive()
|
||||
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
|
||||
@@ -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] {
|
||||
translations, err := i18n.TransStore.GetTranslations(langID, scope, components)
|
||||
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
|
||||
func (s *LangService) GetAllTranslationsResponse(c fiber.Ctx) response.Response[*i18n.TranslationResponse] {
|
||||
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
|
||||
func (s *LangService) ReloadTranslationsResponse(c fiber.Ctx) response.Response[map[string]string] {
|
||||
err := s.ReloadTranslations()
|
||||
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"}
|
||||
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
|
||||
|
||||
59
app/service/listProductsService/listProductsService.go
Normal file
59
app/service/listProductsService/listProductsService.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package listProductsService
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_daniel/b2b/app/model"
|
||||
"git.ma-al.com/goc_daniel/b2b/app/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
|
||||
}
|
||||
26
app/service/localeSelectorService/localeSelectorService.go
Normal file
26
app/service/localeSelectorService/localeSelectorService.go
Normal 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()
|
||||
}
|
||||
8
app/service/meiliService/blank.json
Normal file
8
app/service/meiliService/blank.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
170
app/service/meiliService/meiliService.go
Normal file
170
app/service/meiliService/meiliService.go
Normal 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
|
||||
}
|
||||
92
app/service/menuService/menuService.go
Normal file
92
app/service/menuService/menuService.go
Normal 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 }
|
||||
@@ -12,32 +12,26 @@ import (
|
||||
"strings"
|
||||
"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/db"
|
||||
"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/utils/responseErrors"
|
||||
"github.com/openai/openai-go/v3"
|
||||
"github.com/openai/openai-go/v3/option"
|
||||
"github.com/openai/openai-go/v3/responses"
|
||||
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 {
|
||||
db *gorm.DB
|
||||
ctx context.Context
|
||||
googleCli translate.TranslationClient
|
||||
client openai.Client
|
||||
// projectID is the Google Cloud project ID used as the "parent" in API calls,
|
||||
// e.g. "projects/my-project-123/locations/global"
|
||||
projectID string
|
||||
productDescriptionRepo productDescriptionRepo.UIProductDescriptionRepo
|
||||
ctx context.Context
|
||||
googleCli translate.TranslationClient
|
||||
projectID string
|
||||
openAIClient openai.Client
|
||||
}
|
||||
|
||||
// New creates a ProductDescriptionService and authenticates against the
|
||||
@@ -76,35 +70,24 @@ func New() *ProductDescriptionService {
|
||||
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
|
||||
|
||||
return &ProductDescriptionService{
|
||||
db: db.Get(),
|
||||
ctx: ctx,
|
||||
client: client,
|
||||
googleCli: *googleCli,
|
||||
projectID: cfg.GoogleTranslate.ProjectID,
|
||||
productDescriptionRepo: productDescriptionRepo.New(),
|
||||
ctx: ctx,
|
||||
openAIClient: openAIClient,
|
||||
googleCli: *googleCli,
|
||||
projectID: cfg.GoogleTranslate.ProjectID,
|
||||
}
|
||||
}
|
||||
|
||||
// We assume that any user has access to all product descriptions
|
||||
func (s *ProductDescriptionService) GetProductDescription(userID uint, productID uint, productShopID uint, productLangID uint) (*model.ProductDescription, error) {
|
||||
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
|
||||
func (s *ProductDescriptionService) GetProductDescription(userID uint, productID uint, productLangID uint) (*model.ProductDescription, error) {
|
||||
return s.productDescriptionRepo.GetProductDescription(productID, productLangID)
|
||||
}
|
||||
|
||||
// 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
|
||||
allowedFields := []string{"description", "description_short", "meta_description", "meta_title", "name", "available_now", "available_later", "usage"}
|
||||
for key := range updates {
|
||||
@@ -123,37 +106,12 @@ func (s *ProductDescriptionService) SaveProductDescription(userID uint, productI
|
||||
}
|
||||
}
|
||||
|
||||
record := model.ProductDescription{
|
||||
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
|
||||
err := s.productDescriptionRepo.CreateIfDoesNotExist(productID, productLangID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("database error: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
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
|
||||
return s.productDescriptionRepo.UpdateFields(productID, productLangID, updates)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
var ProductDescription model.ProductDescription
|
||||
func (s *ProductDescriptionService) TranslateProductDescription(userID uint, productID uint, productFromLangID uint, productToLangID uint, aiModel string) (*model.ProductDescription, error) {
|
||||
|
||||
err := s.db.
|
||||
Table("ps_product_lang").
|
||||
Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productFromLangID).
|
||||
First(&ProductDescription).Error
|
||||
productDescription, err := s.productDescriptionRepo.GetProductDescription(productID, productFromLangID)
|
||||
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.
|
||||
lang, err := langsService.LangSrv.GetLanguageById(productToLangID)
|
||||
@@ -180,14 +134,14 @@ func (s *ProductDescriptionService) TranslateProductDescription(userID uint, pro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fields := []*string{&ProductDescription.Description,
|
||||
&ProductDescription.DescriptionShort,
|
||||
&ProductDescription.MetaDescription,
|
||||
&ProductDescription.MetaTitle,
|
||||
&ProductDescription.Name,
|
||||
&ProductDescription.AvailableNow,
|
||||
&ProductDescription.AvailableLater,
|
||||
&ProductDescription.Usage,
|
||||
fields := []*string{&productDescription.Description,
|
||||
&productDescription.DescriptionShort,
|
||||
&productDescription.MetaDescription,
|
||||
&productDescription.MetaTitle,
|
||||
&productDescription.Name,
|
||||
&productDescription.AvailableNow,
|
||||
&productDescription.AvailableLater,
|
||||
&productDescription.Usage,
|
||||
}
|
||||
keys := []string{"translation_of_product_description",
|
||||
"translation_of_product_short_description",
|
||||
@@ -213,24 +167,23 @@ func (s *ProductDescriptionService) TranslateProductDescription(userID uint, pro
|
||||
}
|
||||
|
||||
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)},
|
||||
Model: openai.ChatModelGPT4_1Mini,
|
||||
// Model: openai.ChatModelGPT4_1Nano,
|
||||
})
|
||||
if openai_response.Status != "completed" {
|
||||
if response.Status != "completed" {
|
||||
return nil, responseErrors.ErrAIResponseFail
|
||||
}
|
||||
response := openai_response.OutputText()
|
||||
|
||||
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 {
|
||||
return nil, responseErrors.ErrAIBadOutput
|
||||
}
|
||||
*fields[i] = resolution
|
||||
|
||||
fmt.Println(resolution)
|
||||
// fmt.Println(resolution)
|
||||
}
|
||||
|
||||
} else if aiModel == "Google" {
|
||||
@@ -253,17 +206,17 @@ func (s *ProductDescriptionService) TranslateProductDescription(userID uint, pro
|
||||
response := responseGoogle.GetTranslations()[0].GetTranslatedText()
|
||||
|
||||
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) {
|
||||
return nil, responseErrors.ErrAIBadOutput
|
||||
}
|
||||
*fields[i] = match
|
||||
|
||||
fmt.Println(match)
|
||||
// fmt.Println(match)
|
||||
}
|
||||
}
|
||||
|
||||
return &ProductDescription, nil
|
||||
return productDescription, nil
|
||||
}
|
||||
|
||||
func cleanForPrompt(s string) string {
|
||||
@@ -284,17 +237,17 @@ func cleanForPrompt(s string) string {
|
||||
|
||||
switch v := token.(type) {
|
||||
case xml.StartElement:
|
||||
prompt += "<" + AttrName(v.Name)
|
||||
prompt += "<" + attrName(v.Name)
|
||||
|
||||
for _, attr := range v.Attr {
|
||||
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 += ">"
|
||||
case xml.EndElement:
|
||||
prompt += "</" + AttrName(v.Name) + ">"
|
||||
prompt += "</" + attrName(v.Name) + ">"
|
||||
case xml.CharData:
|
||||
prompt += string(v)
|
||||
case xml.Comment:
|
||||
@@ -307,12 +260,12 @@ func cleanForPrompt(s string) 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) {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
success, resolution := RebuildFromResponse("<"+key+">"+original+"</"+key+">", "<"+key+">"+match+"</"+key+">")
|
||||
success, resolution := rebuildFromResponse("<"+key+">"+original+"</"+key+">", "<"+key+">"+match+"</"+key+">")
|
||||
if !success {
|
||||
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)]
|
||||
}
|
||||
|
||||
// GetStringInBetween returns empty string if no start or end string found
|
||||
func GetStringInBetween(str string, start string, end string) (success bool, result string) {
|
||||
// getStringInBetween returns empty string if no start or end string found
|
||||
func getStringInBetween(str string, start string, end string) (success bool, result string) {
|
||||
s := strings.Index(str, start)
|
||||
if s == -1 {
|
||||
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
|
||||
// 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)
|
||||
d_original := xml.NewDecoder(r_original)
|
||||
@@ -397,17 +350,17 @@ func RebuildFromResponse(s_original string, s_response string) (bool, string) {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
result += "<" + AttrName(v_original.Name)
|
||||
result += "<" + attrName(v_original.Name)
|
||||
|
||||
for _, attr := range v_original.Attr {
|
||||
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 {
|
||||
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 += ">"
|
||||
@@ -429,7 +382,7 @@ func RebuildFromResponse(s_original string, s_response string) (bool, string) {
|
||||
}
|
||||
|
||||
if v_original.Name.Local != "img" {
|
||||
result += "</" + AttrName(v_original.Name) + ">"
|
||||
result += "</" + attrName(v_original.Name) + ">"
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
return name.Local
|
||||
} else {
|
||||
|
||||
@@ -2,3 +2,4 @@ package constdata
|
||||
|
||||
// PASSWORD_VALIDATION_REGEX is used by the frontend (JavaScript supports lookaheads).
|
||||
const PASSWORD_VALIDATION_REGEX = `^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{10,}$`
|
||||
const SHOP_ID = 1
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package pagination
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Paging struct {
|
||||
Page uint `json:"page_number" example:"5"`
|
||||
Elements uint `json:"elements_per_page" example:"30"`
|
||||
}
|
||||
|
||||
func (p Paging) Offset() int {
|
||||
return int(p.Elements) * int(p.Page-1)
|
||||
}
|
||||
|
||||
func (p Paging) Limit() int {
|
||||
return int(p.Elements)
|
||||
}
|
||||
|
||||
type Found[T any] struct {
|
||||
Items []T `json:"items,omitempty"`
|
||||
Count uint `json:"items_count" example:"56"`
|
||||
}
|
||||
|
||||
func Paginate[T any](paging Paging, stmt *gorm.DB) (Found[T], error) {
|
||||
var items []T
|
||||
var count int64
|
||||
|
||||
base := stmt.Session(&gorm.Session{})
|
||||
|
||||
countDB := stmt.Session(&gorm.Session{
|
||||
NewDB: true, // critical: do NOT reuse statement
|
||||
})
|
||||
|
||||
if err := countDB.
|
||||
Table("(?) as sub", base).
|
||||
Count(&count).Error; err != nil {
|
||||
return Found[T]{}, err
|
||||
}
|
||||
|
||||
err := base.
|
||||
Offset(paging.Offset()).
|
||||
Limit(paging.Limit()).
|
||||
Find(&items).
|
||||
Error
|
||||
if err != nil {
|
||||
return Found[T]{}, err
|
||||
}
|
||||
|
||||
return Found[T]{
|
||||
Items: items,
|
||||
Count: uint(count),
|
||||
}, err
|
||||
}
|
||||
150
app/utils/query/filters/filters.go
Normal file
150
app/utils/query/filters/filters.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package filters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type FilterFunction = func(*gorm.DB) *gorm.DB
|
||||
|
||||
func Where(statement string, args ...interface{}) Filter {
|
||||
filt := func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where(statement, args...)
|
||||
}
|
||||
return Filter{
|
||||
category: WHERE_FILTER,
|
||||
filter: filt,
|
||||
}
|
||||
}
|
||||
|
||||
func StructToWhereScope[T any](model T) Filter {
|
||||
filt := func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where(model)
|
||||
}
|
||||
return Filter{
|
||||
category: WHERE_FILTER,
|
||||
filter: filt,
|
||||
}
|
||||
}
|
||||
|
||||
func Order(field string, desc bool) Filter {
|
||||
var filt FilterFunction
|
||||
if desc {
|
||||
filt = func(d *gorm.DB) *gorm.DB {
|
||||
return d.Order(field + " DESC")
|
||||
}
|
||||
} else {
|
||||
filt = func(d *gorm.DB) *gorm.DB {
|
||||
return d.Order(field)
|
||||
}
|
||||
}
|
||||
return Filter{
|
||||
category: ORDER_FILTER,
|
||||
filter: filt,
|
||||
}
|
||||
}
|
||||
|
||||
func WhereFromStrings(column, conditionOperator, value string) Filter {
|
||||
var filt func(*gorm.DB) *gorm.DB
|
||||
|
||||
if strings.HasPrefix(value, "~") {
|
||||
value = strings.ReplaceAll(value, "~", "")
|
||||
|
||||
filt = func(d *gorm.DB) *gorm.DB {
|
||||
return d.Where("lower("+column+`) LIKE lower(?)`, "%"+value+"%")
|
||||
|
||||
}
|
||||
|
||||
return Filter{
|
||||
category: LIKE_FILTER,
|
||||
filter: filt,
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(value, "]") && strings.Contains(value, "[") {
|
||||
period := strings.ReplaceAll(value, "[", "")
|
||||
period = strings.ReplaceAll(period, "]", "")
|
||||
vals := strings.Split(period, ",")
|
||||
if len(vals) == 2 {
|
||||
from, errA := time.Parse("2006-01-02", vals[0])
|
||||
to, errB := time.Parse("2006-01-02", vals[1])
|
||||
if errA == nil && errB == nil {
|
||||
filt = func(d *gorm.DB) *gorm.DB {
|
||||
return d.Where(column+` BETWEEN ? AND ?`, from.Format("2006-01-02"), to.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
return Filter{
|
||||
category: WHERE_FILTER,
|
||||
filter: filt,
|
||||
}
|
||||
} else {
|
||||
filt = func(d *gorm.DB) *gorm.DB {
|
||||
return d.Where(column+` BETWEEN ? AND ?`, vals[0], vals[1])
|
||||
}
|
||||
|
||||
return Filter{
|
||||
category: WHERE_FILTER,
|
||||
filter: filt,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if conditionOperator == "LIKE" {
|
||||
value = fmt.Sprintf("%%%s%%", value)
|
||||
}
|
||||
|
||||
// in future add more grouping functions
|
||||
if strings.Contains(strings.ToLower(column), "count(") {
|
||||
filt = func(d *gorm.DB) *gorm.DB {
|
||||
return d.Having(column+` `+conditionOperator+` ?`, value)
|
||||
}
|
||||
|
||||
return Filter{
|
||||
category: WHERE_FILTER,
|
||||
filter: filt,
|
||||
}
|
||||
}
|
||||
|
||||
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
filt = func(d *gorm.DB) *gorm.DB {
|
||||
return d.Where(column+` `+conditionOperator+` ?`, i)
|
||||
}
|
||||
return Filter{
|
||||
category: WHERE_FILTER,
|
||||
filter: filt,
|
||||
}
|
||||
}
|
||||
if f, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
filt = func(d *gorm.DB) *gorm.DB {
|
||||
return d.Where(column+` `+conditionOperator+` ?`, f)
|
||||
}
|
||||
return Filter{
|
||||
category: WHERE_FILTER,
|
||||
filter: filt,
|
||||
}
|
||||
}
|
||||
if b, err := strconv.ParseBool(value); err == nil {
|
||||
filt = func(d *gorm.DB) *gorm.DB {
|
||||
return d.Where(column+` `+conditionOperator+` ?`, b)
|
||||
}
|
||||
return Filter{
|
||||
category: WHERE_FILTER,
|
||||
filter: filt,
|
||||
}
|
||||
}
|
||||
|
||||
filt = func(d *gorm.DB) *gorm.DB {
|
||||
return d.Where(column+` `+conditionOperator+` ?`, value)
|
||||
}
|
||||
|
||||
return Filter{
|
||||
category: WHERE_FILTER,
|
||||
filter: filt,
|
||||
}
|
||||
}
|
||||
107
app/utils/query/filters/filters_list.go
Normal file
107
app/utils/query/filters/filters_list.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package filters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Use one of declared in the package constants to instantiate the type.
|
||||
type filterCategory = string
|
||||
|
||||
// Enumaration of known types of filters. The assumption is that all filters
|
||||
// belonging to a single category (type) can be used together at a particular
|
||||
// step in the query process.
|
||||
const (
|
||||
// Should be safe to use at any step of longer query series to reduce the
|
||||
// number of results. If it is not, choose a different filter type
|
||||
WHERE_FILTER filterCategory = "where"
|
||||
|
||||
// An like filter
|
||||
LIKE_FILTER filterCategory = "where"
|
||||
|
||||
// An order by clause which can be used at any final step of a complex query
|
||||
// to change the order of results.
|
||||
ORDER_FILTER filterCategory = "order"
|
||||
// TODO: document the special case of filters on products
|
||||
FEAT_VAL_PRODUCT_FILTER filterCategory = "featval_product"
|
||||
)
|
||||
|
||||
type Filter struct {
|
||||
category filterCategory
|
||||
filter func(*gorm.DB) *gorm.DB
|
||||
}
|
||||
|
||||
func NewFilter(category filterCategory, filter func(*gorm.DB) *gorm.DB) Filter {
|
||||
return Filter{
|
||||
category: category,
|
||||
filter: filter,
|
||||
}
|
||||
}
|
||||
|
||||
type FiltersList struct {
|
||||
filters []Filter
|
||||
}
|
||||
|
||||
func NewFiltersList() FiltersList {
|
||||
return FiltersList{
|
||||
// we allocate some extra space beforehand to reduce the overhead of resizing
|
||||
filters: make([]Filter, 0, 3),
|
||||
}
|
||||
}
|
||||
|
||||
func NewListWithFilter(filt Filter) FiltersList {
|
||||
l := NewFiltersList()
|
||||
l.filters = append(l.filters, filt)
|
||||
return l
|
||||
}
|
||||
|
||||
func (f *FiltersList) NewFilter(category filterCategory, filter func(*gorm.DB) *gorm.DB) {
|
||||
f.filters = append(f.filters, NewFilter(category, filter))
|
||||
}
|
||||
|
||||
func (f *FiltersList) Append(filter ...Filter) {
|
||||
f.filters = append(f.filters, filter...)
|
||||
}
|
||||
|
||||
// Return all stored filters as []func(*gorm.DB)*gorm.DB
|
||||
func (f *FiltersList) All() []func(*gorm.DB) *gorm.DB {
|
||||
return lo.Map(f.filters, func(filt Filter, _ int) func(*gorm.DB) *gorm.DB {
|
||||
return filt.filter
|
||||
})
|
||||
}
|
||||
|
||||
func (f *FiltersList) OfCategory(cat filterCategory) []func(*gorm.DB) *gorm.DB {
|
||||
return lo.Map(lo.Filter(f.filters, func(v Filter, _ int) bool {
|
||||
return v.category == cat
|
||||
}), func(el Filter, _ int) func(*gorm.DB) *gorm.DB {
|
||||
return el.filter
|
||||
})
|
||||
}
|
||||
|
||||
func (f *FiltersList) ApplyAll(d *gorm.DB) {
|
||||
d.Scopes(f.All()...)
|
||||
}
|
||||
|
||||
func (f *FiltersList) Apply(d *gorm.DB, cat filterCategory) {
|
||||
d.Scopes(f.OfCategory(cat)...)
|
||||
}
|
||||
|
||||
func (f *FiltersList) Merge(another FiltersList) {
|
||||
f.filters = append(f.filters, another.filters...)
|
||||
}
|
||||
|
||||
// An implementation of stringer on FiltersList that is meant rather to be used
|
||||
// for debug display
|
||||
func (f FiltersList) String() string {
|
||||
groupMap := lo.GroupBy(f.filters, func(t Filter) string {
|
||||
return t.category
|
||||
})
|
||||
res := "FiltersList{"
|
||||
for key := range groupMap {
|
||||
res += fmt.Sprintf(" \"%s\": %d filters", key, len(groupMap[key]))
|
||||
}
|
||||
res += " }"
|
||||
return res
|
||||
}
|
||||
159
app/utils/query/find/find.go
Normal file
159
app/utils/query/find/find.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package find
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Paging struct {
|
||||
Page uint `json:"page_number" example:"5"`
|
||||
Elements uint `json:"elements_per_page" example:"30"`
|
||||
}
|
||||
|
||||
func (p Paging) Offset() int {
|
||||
return int(p.Elements) * int(p.Page-1)
|
||||
}
|
||||
|
||||
func (p Paging) Limit() int {
|
||||
return int(p.Elements)
|
||||
}
|
||||
|
||||
type Found[T any] struct {
|
||||
Items []T `json:"items,omitempty"`
|
||||
Count uint `json:"items_count" example:"56"`
|
||||
Spec map[string]interface{} `json:"spec,omitempty"`
|
||||
}
|
||||
|
||||
// Wraps given query adding limit, offset clauses and SQL_CALC_FOUND_ROWS to it
|
||||
// and running SELECT FOUND_ROWS() afterwards to fetch the total number
|
||||
// (ignoring LIMIT) of results. The final results are wrapped into the
|
||||
// [find.Found] type.
|
||||
func Paginate[T any](langID uint, paging Paging, stmt *gorm.DB) (Found[T], error) {
|
||||
var items []T
|
||||
var count uint64
|
||||
|
||||
// stmt.Debug()
|
||||
|
||||
err := stmt.
|
||||
Clauses(SqlCalcFound()).
|
||||
Offset(paging.Offset()).
|
||||
Limit(paging.Limit()).
|
||||
Find(&items).
|
||||
Error
|
||||
if err != nil {
|
||||
return Found[T]{}, err
|
||||
}
|
||||
|
||||
countInterface, ok := stmt.Get(FOUND_ROWS_CTX_KEY)
|
||||
if !ok {
|
||||
return Found[T]{}, errors.New(FOUND_ROWS_CTX_KEY + " value was not found in the gorm db context")
|
||||
}
|
||||
if count, ok = countInterface.(uint64); !ok {
|
||||
return Found[T]{}, errors.New("failed to cast value under " + FOUND_ROWS_CTX_KEY + " to uint64")
|
||||
}
|
||||
|
||||
columnsSpec := GetColumnsSpec[T](langID)
|
||||
|
||||
return Found[T]{
|
||||
Items: items,
|
||||
Count: uint(count),
|
||||
Spec: map[string]interface{}{
|
||||
"columns": columnsSpec,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
// GetColumnsSpec[T any] generates a column specification map for a given struct type T.
|
||||
// Each key is the JSON property name, and the value is a map containing:
|
||||
// - "filter_type": suggested filter type based on field type or `filt` tag
|
||||
// - To disable filtering for a field, set `filt:"none"` in the struct tag
|
||||
// - "sortable": currently hardcoded to true
|
||||
// - "order": order of fields as they appear
|
||||
//
|
||||
// Returns nil if T is not a struct.
|
||||
func GetColumnsSpec[T any](langID uint) map[string]map[string]interface{} {
|
||||
result := make(map[string]map[string]interface{})
|
||||
typ := reflect.TypeOf((*T)(nil)).Elem()
|
||||
if typ.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
order := 1
|
||||
processStructFields(langID, typ, result, &order)
|
||||
return result
|
||||
}
|
||||
|
||||
type FilterType string
|
||||
|
||||
const (
|
||||
FilterTypeRange FilterType = "range"
|
||||
FilterTypeTimerange FilterType = "timerange"
|
||||
FilterTypeLike FilterType = "like"
|
||||
FilterTypeSwitch FilterType = "switch"
|
||||
FilterTypeNone FilterType = "none"
|
||||
)
|
||||
|
||||
func isValidFilterType(ft string) bool {
|
||||
switch FilterType(ft) {
|
||||
case FilterTypeRange, FilterTypeTimerange, FilterTypeLike, FilterTypeSwitch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// processStructFields recursively processes struct fields to populate the result map.
|
||||
// It handles inline structs, reads `json` and `filt` tags, and determines filter types
|
||||
// based on the field type when `filt` tag is absent.
|
||||
// `order` is incremented for each field to track field ordering.
|
||||
func processStructFields(langID uint, typ reflect.Type, result map[string]map[string]interface{}, order *int) {
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
propName := strings.Split(jsonTag, ",")[0]
|
||||
if propName == "" {
|
||||
propName = field.Name
|
||||
}
|
||||
if strings.Contains(jsonTag, ",inline") && field.Type.Kind() == reflect.Struct {
|
||||
processStructFields(langID, field.Type, result, order)
|
||||
continue
|
||||
}
|
||||
|
||||
filterType := field.Tag.Get("filt")
|
||||
if filterType != "" {
|
||||
if !isValidFilterType(filterType) {
|
||||
filterType = string(FilterTypeNone)
|
||||
}
|
||||
} else {
|
||||
fieldType := field.Type.String()
|
||||
switch {
|
||||
case strings.HasPrefix(fieldType, "int"), strings.HasPrefix(fieldType, "uint"), strings.HasPrefix(fieldType, "float"), strings.HasPrefix(fieldType, "decimal.Decimal"):
|
||||
filterType = string(FilterTypeRange)
|
||||
case strings.Contains(fieldType, "Time"):
|
||||
filterType = string(FilterTypeTimerange)
|
||||
case fieldType == "string":
|
||||
filterType = string(FilterTypeLike)
|
||||
case fieldType == "bool":
|
||||
filterType = string(FilterTypeSwitch)
|
||||
default:
|
||||
filterType = string(FilterTypeNone)
|
||||
}
|
||||
}
|
||||
|
||||
result[propName] = map[string]interface{}{
|
||||
"filter_type": filterType,
|
||||
"sortable": func() bool { val, ok := field.Tag.Lookup("sortable"); return !ok || val == "true" }(),
|
||||
"order": *order,
|
||||
"title": i18n.T___(langID, field.Tag.Get("title")),
|
||||
"display": func() bool { val, ok := field.Tag.Lookup("display"); return !ok || val == "true" }(),
|
||||
"hidden": field.Tag.Get("hidden") == "true",
|
||||
}
|
||||
*order++
|
||||
}
|
||||
}
|
||||
46
app/utils/query/find/found_rows_callback.go
Normal file
46
app/utils/query/find/found_rows_callback.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package find
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// Key under which result of `SELECT FOUND_ROWS()` should be stored in the
|
||||
// driver context.
|
||||
FOUND_ROWS_CTX_KEY = "maal:found_rows"
|
||||
// Suggested name under which [find.FoundRowsCallback] can be registered.
|
||||
FOUND_ROWS_CALLBACK = "maal:found_rows"
|
||||
)
|
||||
|
||||
// Searches query clauses for presence of `SQL_CALC_FOUND_ROWS` and runs `SELECT
|
||||
// FOUND_ROWS();` right after the query containing such clause. The result is
|
||||
// put in the driver context under key [find.FOUND_ROWS_CTX_KEY]. For the
|
||||
// callback to work correctly it must be registered and executed before the
|
||||
// `gorm:preload` callback.
|
||||
func FoundRowsCallback(d *gorm.DB) {
|
||||
if _, ok := d.Statement.Clauses["SELECT"].AfterNameExpression.(sqlCalcFound); ok {
|
||||
var count uint64
|
||||
sqlDB, err := d.DB()
|
||||
if err != nil {
|
||||
_ = d.AddError(err)
|
||||
return
|
||||
}
|
||||
res := sqlDB.QueryRowContext(d.Statement.Context, "SELECT FOUND_ROWS();")
|
||||
if res == nil {
|
||||
_ = d.AddError(errors.New(`fialed to issue SELECT FOUND_ROWS() query`))
|
||||
return
|
||||
}
|
||||
if res.Err() != nil {
|
||||
_ = d.AddError(res.Err())
|
||||
return
|
||||
}
|
||||
err = res.Scan(&count)
|
||||
if err != nil {
|
||||
_ = d.AddError(err)
|
||||
return
|
||||
}
|
||||
d.Set(FOUND_ROWS_CTX_KEY, count)
|
||||
}
|
||||
}
|
||||
51
app/utils/query/find/sql_calc_rows.go
Normal file
51
app/utils/query/find/sql_calc_rows.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package find
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type sqlCalcFound struct{}
|
||||
|
||||
// Creates a new Clause which adds `SQL_CALC_FOUND_ROWS` right after `SELECT`.
|
||||
// If [find.FoundRowsCallback] is registered the presence of this clause will
|
||||
// cause `FOUND_ROWS()` result to be available in the driver context.
|
||||
func SqlCalcFound() sqlCalcFound {
|
||||
return sqlCalcFound{}
|
||||
}
|
||||
|
||||
// Implements gorm's [clause.Clause]
|
||||
func (sqlCalcFound) Name() string {
|
||||
return "SQL_CALC_FOUND_ROWS"
|
||||
}
|
||||
|
||||
// Implements gorm's [clause.Clause]
|
||||
func (sqlCalcFound) Build(builder clause.Builder) {
|
||||
_, _ = builder.WriteString("SQL_CALC_FOUND_ROWS")
|
||||
}
|
||||
|
||||
// Implements gorm's [clause.Clause]
|
||||
func (sqlCalcFound) MergeClause(cl *clause.Clause) {
|
||||
}
|
||||
|
||||
// Implements [gorm.StatementModifier]
|
||||
func (calc sqlCalcFound) ModifyStatement(stmt *gorm.Statement) {
|
||||
selectClause := stmt.Clauses["SELECT"]
|
||||
if selectClause.AfterNameExpression == nil {
|
||||
selectClause.AfterNameExpression = calc
|
||||
} else if _, ok := selectClause.AfterNameExpression.(sqlCalcFound); !ok {
|
||||
selectClause.AfterNameExpression = exprs{selectClause.AfterNameExpression, calc}
|
||||
}
|
||||
stmt.Clauses["SELECT"] = selectClause
|
||||
}
|
||||
|
||||
type exprs []clause.Expression
|
||||
|
||||
func (exprs exprs) Build(builder clause.Builder) {
|
||||
for idx, expr := range exprs {
|
||||
if idx > 0 {
|
||||
_ = builder.WriteByte(' ')
|
||||
}
|
||||
expr.Build(builder)
|
||||
}
|
||||
}
|
||||
43
app/utils/query/query_params/key_mapping.go
Normal file
43
app/utils/query/query_params/key_mapping.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package query_params
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
mreflect "git.ma-al.com/goc_daniel/b2b/app/utils/reflect"
|
||||
)
|
||||
|
||||
// MapParamsKeyToDbColumn will attempt to map provided key into unique (prefixed
|
||||
// with table) column name. It will do so using following priority of sources of
|
||||
// mapping:
|
||||
// 1. `formColumnMapping` argument. If the mapped values contain a dot, the part
|
||||
// before the dot will be used for the table name. Otherwise the table name will
|
||||
// be derived from the generic parameter `T`.
|
||||
// 2. json tags of provided as generic `T` struct. The table name will be also
|
||||
// derived from the generic if not provided as dot prefix.
|
||||
func MapParamsKeyToDbColumn[DEFAULT_TABLE_MODEL any](key string, mapping ...map[string]string) (string, error) {
|
||||
ERR := "Failed to find appropiate mapping from form field to database column for key: '%s', and default table name: '%s'"
|
||||
|
||||
if len(mapping) > 0 {
|
||||
if field, ok := (mapping[0])[key]; ok {
|
||||
return field, nil
|
||||
}
|
||||
} else {
|
||||
var t DEFAULT_TABLE_MODEL
|
||||
if table, field, ok := strings.Cut(key, "."); ok {
|
||||
if column, err := mreflect.GetGormColumnFromJsonField(field, reflect.TypeOf(t)); err == nil {
|
||||
return table + "." + column, nil
|
||||
}
|
||||
return "", fmt.Errorf(ERR, key, table)
|
||||
} else {
|
||||
table := mreflect.GetTableName[DEFAULT_TABLE_MODEL]()
|
||||
if column, err := mreflect.GetGormColumnFromJsonField(key, reflect.TypeOf(t)); err == nil {
|
||||
return table + "." + column, nil
|
||||
} else {
|
||||
return "", fmt.Errorf(ERR, key, table)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf(ERR, key, mreflect.GetTableName[DEFAULT_TABLE_MODEL]())
|
||||
}
|
||||
63
app/utils/query/query_params/params_query.go
Normal file
63
app/utils/query/query_params/params_query.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package query_params
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.ma-al.com/goc_daniel/b2b/app/utils/query/filters"
|
||||
"git.ma-al.com/goc_daniel/b2b/app/utils/query/find"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
var FunctionalQueryParams = []string{
|
||||
// Used to specidy order of results
|
||||
"sort",
|
||||
// Used to specify page of search resulst
|
||||
"p",
|
||||
// Used to specify number of elements on a page
|
||||
"elems",
|
||||
// Used to specify allowed values of features on products
|
||||
"values",
|
||||
}
|
||||
|
||||
func ParseFilters[T any](c fiber.Ctx, formColumnMappimg ...map[string]string) (find.Paging, *filters.FiltersList, error) {
|
||||
// field/column based filters
|
||||
filters, err := ParseFieldFilters[T](c, formColumnMappimg...)
|
||||
if err != nil {
|
||||
return find.Paging{}, filters, err
|
||||
}
|
||||
// pagination
|
||||
pageNum, pageSize := ParsePagination(c)
|
||||
|
||||
// ret
|
||||
return find.Paging{Page: pageNum, Elements: pageSize}, filters, nil
|
||||
}
|
||||
|
||||
// Parse field related filters from params query. Produces where clauses and
|
||||
// order rules.
|
||||
func ParseFieldFilters[T any](c fiber.Ctx, formColumnMapping ...map[string]string) (*filters.FiltersList, error) {
|
||||
// var model T
|
||||
list := filters.NewFiltersList()
|
||||
|
||||
whereScopefilters := ParseWhereScopes[T](c, []string{}, formColumnMapping...)
|
||||
list.Append(whereScopefilters...)
|
||||
|
||||
ord, err := ParseOrdering[T](c, formColumnMapping...)
|
||||
if err != nil {
|
||||
return &list, err
|
||||
}
|
||||
// addDefaultOrderingIfNeeded(&ord, model)
|
||||
for i := range ord {
|
||||
if err == nil {
|
||||
list.Append(filters.Order(ord[i].Column, ord[i].IsDesc))
|
||||
}
|
||||
}
|
||||
|
||||
return &list, nil
|
||||
}
|
||||
|
||||
// TODO: Add some source of defaults for pagination size here
|
||||
func ParsePagination(c fiber.Ctx) (uint, uint) {
|
||||
pageNum, _ := strconv.ParseInt(c.Query("p", "1"), 10, 64)
|
||||
pageSize, _ := strconv.ParseInt(c.Query("elems", "30"), 10, 64)
|
||||
return uint(pageNum), uint(pageSize)
|
||||
}
|
||||
82
app/utils/query/query_params/parse_sort.go
Normal file
82
app/utils/query/query_params/parse_sort.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package query_params
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type Ordering struct {
|
||||
Column string
|
||||
IsDesc bool
|
||||
}
|
||||
|
||||
func ParseOrdering[T any](c fiber.Ctx, columnMapping ...map[string]string) ([]Ordering, error) {
|
||||
param := c.Query("sort")
|
||||
if len(param) < 1 {
|
||||
return []Ordering{}, nil
|
||||
}
|
||||
|
||||
rules := strings.Split(param, ";")
|
||||
var orderings []Ordering
|
||||
for _, r := range rules {
|
||||
ord, err := parseOrderingRule[T](r, columnMapping...)
|
||||
if err != nil {
|
||||
return orderings, err
|
||||
}
|
||||
orderings = append(orderings, ord)
|
||||
}
|
||||
return orderings, nil
|
||||
}
|
||||
|
||||
func parseOrderingRule[T any](rule string, columnMapping ...map[string]string) (Ordering, error) {
|
||||
var desc bool
|
||||
if key, descStr, ok := strings.Cut(rule, ","); ok {
|
||||
switch {
|
||||
case strings.Compare(descStr, "desc") == 0:
|
||||
desc = true
|
||||
case strings.Compare(descStr, "asc") == 0:
|
||||
desc = false
|
||||
default:
|
||||
desc = true
|
||||
}
|
||||
if col, err := MapParamsKeyToDbColumn[T](key, columnMapping...); err == nil {
|
||||
return Ordering{
|
||||
Column: col,
|
||||
IsDesc: desc,
|
||||
}, nil
|
||||
} else {
|
||||
return Ordering{}, err
|
||||
}
|
||||
} else {
|
||||
if col, err := MapParamsKeyToDbColumn[T](key, columnMapping...); err == nil {
|
||||
return Ordering{
|
||||
Column: col,
|
||||
IsDesc: true,
|
||||
}, nil
|
||||
} else {
|
||||
return Ordering{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// func addDefaultOrderingIfNeeded[T any](previousOrderings *[]Ordering, model T) {
|
||||
// newOrderings := new([]Ordering)
|
||||
// var t T
|
||||
// if len(*previousOrderings) < 1 {
|
||||
// if col, err := mreflect.GetGormColumnFromJsonField("id", reflect.TypeOf(t)); err == nil {
|
||||
// *newOrderings = append(*newOrderings, Ordering{
|
||||
// Column: mreflect.GetTableName[T]() + "." + col,
|
||||
// IsDesc: true,
|
||||
// })
|
||||
// }
|
||||
// if col, err := mreflect.GetGormColumnFromJsonField("iso_code", reflect.TypeOf(t)); err == nil {
|
||||
// *newOrderings = append(*newOrderings, Ordering{
|
||||
// Column: mreflect.GetTableName[T]() + "." + col,
|
||||
// IsDesc: false,
|
||||
// })
|
||||
// }
|
||||
// *newOrderings = append(*newOrderings, *previousOrderings...)
|
||||
// *previousOrderings = *newOrderings
|
||||
// }
|
||||
// }
|
||||
75
app/utils/query/query_params/where_scope_from_query.go
Normal file
75
app/utils/query/query_params/where_scope_from_query.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package query_params
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.ma-al.com/goc_daniel/b2b/app/utils/query/filters"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ParseWhereScopes will attempt to create where scope query filters from url
|
||||
// query params. It will map form fields to a database column name using
|
||||
// `MapParamsKeyToDbColumn` function.
|
||||
func ParseWhereScopes[T any](c fiber.Ctx, ignoredKeys []string, formColumnMapping ...map[string]string) []filters.Filter {
|
||||
var parsedFilters []filters.Filter
|
||||
//nolint
|
||||
for key, value := range c.Request().URI().QueryArgs().All() {
|
||||
keyStr := string(key)
|
||||
valStr := string(value)
|
||||
|
||||
isIgnored := false
|
||||
for _, ignoredKey := range ignoredKeys {
|
||||
if keyStr == ignoredKey {
|
||||
isIgnored = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isIgnored {
|
||||
continue
|
||||
}
|
||||
|
||||
baseKey, operator := extractOperator(keyStr)
|
||||
|
||||
if col, err := MapParamsKeyToDbColumn[T](baseKey, formColumnMapping...); err == nil {
|
||||
if strings.HasPrefix(valStr, "~") {
|
||||
parsedFilters = append(parsedFilters, filters.WhereFromStrings(col, "LIKE", valStr))
|
||||
continue
|
||||
}
|
||||
|
||||
op := resolveOperator(operator)
|
||||
|
||||
parsedFilters = append(parsedFilters, filters.WhereFromStrings(col, op, valStr))
|
||||
}
|
||||
}
|
||||
|
||||
return parsedFilters
|
||||
}
|
||||
|
||||
func extractOperator(key string) (base string, operatorSuffix string) {
|
||||
suffixes := []string{"_gt", "_gte", "_lt", "_lte", "_eq", "_neq"}
|
||||
for _, suf := range suffixes {
|
||||
if strings.HasSuffix(key, suf) {
|
||||
return strings.TrimSuffix(key, suf), suf[1:]
|
||||
}
|
||||
}
|
||||
return key, ""
|
||||
}
|
||||
|
||||
func resolveOperator(suffix string) string {
|
||||
switch suffix {
|
||||
case "gt":
|
||||
return ">"
|
||||
case "gte":
|
||||
return ">="
|
||||
case "lt":
|
||||
return "<"
|
||||
case "lte":
|
||||
return "<="
|
||||
case "neq":
|
||||
return "!="
|
||||
case "eq":
|
||||
return "="
|
||||
default:
|
||||
return "LIKE"
|
||||
}
|
||||
}
|
||||
37
app/utils/query/queryparser/queryparser.go
Normal file
37
app/utils/query/queryparser/queryparser.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package queryparser
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func ParseQuery(c fiber.Ctx) map[string]interface{} {
|
||||
queryParams := map[string]interface{}{}
|
||||
re := regexp.MustCompile(`\?(\w.+)$`)
|
||||
xx := re.FindAllStringSubmatch(c.Request().URI().String(), -1)
|
||||
|
||||
if len(xx) > 0 {
|
||||
if len(xx[0]) == 2 {
|
||||
queryParts := strings.Split(xx[0][1], "&")
|
||||
for _, q := range queryParts {
|
||||
qq := strings.Split(q, "=")
|
||||
if len(qq) == 2 {
|
||||
if num, err := strconv.ParseInt(qq[1], 10, 64); err == nil {
|
||||
queryParams[qq[0]] = num
|
||||
} else if float, err := strconv.ParseFloat(qq[1], 64); err == nil {
|
||||
queryParams[qq[0]] = float
|
||||
} else {
|
||||
queryParams[qq[0]] = qq[1]
|
||||
}
|
||||
} else {
|
||||
queryParams[qq[0]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queryParams
|
||||
}
|
||||
90
app/utils/reflect/reflect.go
Normal file
90
app/utils/reflect/reflect.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package reflect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.ma-al.com/goc_daniel/b2b/app/db"
|
||||
)
|
||||
|
||||
// TODO: instead of matching with string.Contains use something less error-prone
|
||||
func checkIfContainsJSON(i int, t reflect.Type, name string) string {
|
||||
if wholeTag, ok := t.Field(i).Tag.Lookup("json"); ok {
|
||||
tags := strings.Split(wholeTag, ",")
|
||||
for _, tag := range tags {
|
||||
if name == strings.TrimSpace(tag) {
|
||||
return db.DB.NamingStrategy.ColumnName(t.Name(), t.Field(i).Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Not tail recursive but should do fine. Goes only as deep as the hierarchy of
|
||||
// inlined structs.
|
||||
// TODO: improve used internally checkIfContainsJSON
|
||||
func GetGormColumnFromJsonField(jsonName string, t reflect.Type) (string, error) {
|
||||
var res string
|
||||
for i := range make([]bool, t.NumField()) {
|
||||
if tag, ok := t.Field(i).Tag.Lookup("json"); ok && strings.Contains(tag, "inline") {
|
||||
var err error
|
||||
res, err = GetGormColumnFromJsonField(jsonName, t.Field(i).Type)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no field of struct %q has a name %q in its json form", t.Name(), jsonName)
|
||||
}
|
||||
|
||||
} else {
|
||||
res = checkIfContainsJSON(i, t, jsonName)
|
||||
}
|
||||
if res != "" {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no field of struct %q has a name %q in its json form", t.Name(), jsonName)
|
||||
}
|
||||
|
||||
func GetTableName[T any]() string {
|
||||
var model T
|
||||
typ := reflect.TypeOf(model).Name()
|
||||
return db.DB.NamingStrategy.TableName(typ)
|
||||
}
|
||||
|
||||
func GetParamFromFieldTag[T any](object T, fieldname string, tagname string, paramname string) string {
|
||||
if table, ok := reflect.TypeOf(object).FieldByName(fieldname); ok {
|
||||
if t, ok := table.Tag.Lookup(tagname); ok {
|
||||
if paramname == "" {
|
||||
return t
|
||||
}
|
||||
re := regexp.MustCompile(`(?m)` + paramname + `:(\w*)`)
|
||||
f := re.FindAllStringSubmatch(t, -1)
|
||||
if len(re.FindAllStringSubmatch(t, -1)) > 0 {
|
||||
return f[0][1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetPrimaryKey[T any](item T) string {
|
||||
var search func(T) string = func(item T) string {
|
||||
val := reflect.ValueOf(item)
|
||||
typ := reflect.TypeOf(item)
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
if gg, ok := typ.Field(i).Tag.Lookup("gorm"); ok {
|
||||
xx := strings.Split(gg, ";")
|
||||
for _, t := range xx {
|
||||
if strings.HasPrefix(strings.ToLower(t), "primarykey") {
|
||||
return db.DB.NamingStrategy.TableName(typ.Field(i).Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if val.Field(i).Type().String() == "db.Model" {
|
||||
return db.DB.NamingStrategy.TableName("ID")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return search(item)
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
package response
|
||||
|
||||
import "github.com/gofiber/fiber/v3"
|
||||
|
||||
type Response[T any] struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
Items *T `json:"items,omitempty"`
|
||||
Count *int `json:"count,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Items *T `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func Make[T any](c fiber.Ctx, status int, items *T, count *int, message string) Response[T] {
|
||||
c.Status(status)
|
||||
func Make[T any](items *T, count int, message string) Response[T] {
|
||||
return Response[T]{
|
||||
Message: message,
|
||||
Items: items,
|
||||
|
||||
@@ -25,6 +25,8 @@ var (
|
||||
ErrEmailRequired = errors.New("email is required")
|
||||
ErrEmailPasswordRequired = errors.New("email and password are 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
|
||||
ErrInvalidResetToken = errors.New("invalid reset token")
|
||||
@@ -38,11 +40,17 @@ var (
|
||||
ErrVerificationTokenExpired = errors.New("verification token has expired")
|
||||
|
||||
// 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")
|
||||
ErrInvalidXHTML = errors.New("text is not in xhtml format")
|
||||
ErrAIResponseFail = errors.New("AI responded with failure")
|
||||
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
|
||||
@@ -95,6 +103,10 @@ func GetErrorCode(c fiber.Ctx, err error) string {
|
||||
return i18n.T_(c, "error.err_token_required")
|
||||
case errors.Is(err, ErrRefreshTokenRequired):
|
||||
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):
|
||||
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):
|
||||
return i18n.T_(c, "error.err_invalid_html")
|
||||
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):
|
||||
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:
|
||||
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, ErrTokenRequired),
|
||||
errors.Is(err, ErrRefreshTokenRequired),
|
||||
errors.Is(err, ErrBadLangID),
|
||||
errors.Is(err, ErrBadCountryID),
|
||||
errors.Is(err, ErrPasswordsDoNotMatch),
|
||||
errors.Is(err, ErrTokenPasswordRequired),
|
||||
errors.Is(err, ErrInvalidResetToken),
|
||||
@@ -154,7 +174,9 @@ func GetErrorStatus(err error) int {
|
||||
errors.Is(err, ErrInvalidPassword),
|
||||
errors.Is(err, ErrBadAttribute),
|
||||
errors.Is(err, ErrBadField),
|
||||
errors.Is(err, ErrInvalidXHTML):
|
||||
errors.Is(err, ErrInvalidXHTML),
|
||||
errors.Is(err, ErrBadPaging),
|
||||
errors.Is(err, ErrNoRootFound):
|
||||
return fiber.StatusBadRequest
|
||||
case errors.Is(err, ErrEmailExists):
|
||||
return fiber.StatusConflict
|
||||
10
bo/components.d.ts
vendored
10
bo/components.d.ts
vendored
@@ -16,8 +16,15 @@ declare module 'vue' {
|
||||
En_PrivacyPolicyView: typeof import('./src/components/terms/en_PrivacyPolicyView.vue')['default']
|
||||
En_TermsAndConditionsView: typeof import('./src/components/terms/en_TermsAndConditionsView.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_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']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
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']
|
||||
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']
|
||||
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']
|
||||
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']
|
||||
|
||||
@@ -2,10 +2,6 @@ import type { NuxtUIOptions } from '@nuxt/ui/unplugin'
|
||||
|
||||
export const uiOptions: NuxtUIOptions = {
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'blue',
|
||||
neutral: 'zink',
|
||||
},
|
||||
pagination: {
|
||||
slots: {
|
||||
root: '',
|
||||
@@ -22,6 +18,13 @@ export const uiOptions: NuxtUIOptions = {
|
||||
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: {
|
||||
slots: {
|
||||
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: {
|
||||
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!',
|
||||
}
|
||||
|
||||
},
|
||||
modal: {
|
||||
slots: {
|
||||
content: 'border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! bg-(--second-light) dark:bg-(--main-dark)',
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const authStore = useAuthStore()
|
||||
<template>
|
||||
<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)">
|
||||
<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">
|
||||
<!-- Logo -->
|
||||
<RouterLink :to="{ name: 'home' }" class="flex items-center gap-2">
|
||||
@@ -22,6 +22,15 @@ const authStore = useAuthStore()
|
||||
<RouterLink :to="{ name: 'product-detail' }">
|
||||
product detail
|
||||
</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">
|
||||
<!-- Language Switcher -->
|
||||
<LangSwitch />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import ProductsView from '@/views/customer/ProductsView.vue';
|
||||
import LangSwitch from './inner/langSwitch.vue'
|
||||
import ThemeSwitch from './inner/themeSwitch.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<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">
|
||||
<USelect v-model="selectedLanguage" :items="availableLangs" variant="outline" class="w-40!"
|
||||
valueKey="iso_code">
|
||||
<USelect v-model="selectedLanguage" :items="availableLangs" variant="outline" class="w-40!" valueKey="iso_code">
|
||||
<template #default="{ modelValue }">
|
||||
<div class="flex items-center gap-2">
|
||||
<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="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>
|
||||
</div>
|
||||
</template>
|
||||
<template #item-leading="{ item }">
|
||||
@@ -19,7 +20,8 @@
|
||||
</template>
|
||||
</USelect>
|
||||
</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
|
||||
</UButton>
|
||||
</div>
|
||||
@@ -33,23 +35,24 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-30">
|
||||
<div class="flex flex-col gap-10">
|
||||
<p class="p-60 bg-yellow-300">img</p>
|
||||
</div>
|
||||
<p class="p-80 bg-(--second-light)">img</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-[25px] font-bold dark:text-white text-black">{{ productStore.productDescription.name }}</p>
|
||||
<p v-html="productStore.productDescription.description_short" class="dark:text-white text-black"></p>
|
||||
|
||||
|
||||
<div class="space-[10px]">
|
||||
<div class="flex gap-1 items-center">
|
||||
<p class="text-[25px] font-bold text-black dark:text-white">
|
||||
{{ productStore.productDescription.name }}
|
||||
</p>
|
||||
<p v-html="productStore.productDescription.description_short" class="text-black dark:text-white"></p>
|
||||
<div class="space-y-[10px]">
|
||||
<div class="flex items-center gap-1">
|
||||
<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)">{{
|
||||
productStore.productDescription.available_now }}</p>
|
||||
<p class="text-[16px] font-bold text-(--accent-blue-light) dark:text-(--accent-blue-dark)">
|
||||
{{ productStore.productDescription.available_now }}
|
||||
</p>
|
||||
</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" />
|
||||
<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>
|
||||
@@ -70,30 +73,39 @@
|
||||
</UButton>
|
||||
</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 class="flex justify-end items-center gap-3 mb-4">
|
||||
<UButton @click="usageEdit.enableEdit()" class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
|
||||
<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 === '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 === '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 @click="descriptionEdit.enableEdit()" class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
|
||||
<div class="flex justify-end items-center gap-3 mb-4">
|
||||
<UButton v-if="!isEditing" @click="enableEdit"
|
||||
class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
|
||||
<p class="text-white">Change Text</p>
|
||||
<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">
|
||||
<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>
|
||||
</UButton>
|
||||
<UButton v-if="descriptionEdit.isEditing.value" @click="cancelDescriptionEdit" color="neutral" variant="outline" class="p-2.5 cursor-pointer">Cancel</UButton>
|
||||
</div>
|
||||
<div ref="descriptionRef" v-html="productStore.productDescription.description"
|
||||
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))
|
||||
// })
|
||||
|
||||
const isEditing = ref(false)
|
||||
|
||||
const availableLangs = computed(() => langs)
|
||||
|
||||
const selectedLanguage = ref('pl')
|
||||
@@ -152,12 +166,50 @@ const usageRef = ref<HTMLElement | null>(null)
|
||||
const descriptionEdit = useEditable(descriptionRef)
|
||||
const usageEdit = useEditable(usageRef)
|
||||
|
||||
const save = async () => {
|
||||
const originalDescription = ref('')
|
||||
const originalUsage = ref('')
|
||||
|
||||
const saveDescription = async () => {
|
||||
descriptionEdit.disableEdit()
|
||||
usageEdit.disableEdit()
|
||||
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>
|
||||
|
||||
<style>
|
||||
189
bo/src/components/customer/PageAddresses.vue
Normal file
189
bo/src/components/customer/PageAddresses.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="container mx-auto mt-10">
|
||||
<div class="flex flex-col mb-6">
|
||||
<h1 class="text-2xl font-bold text-black dark:text-white">{{ t('Addresses') }}</h1>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<UInput v-model="searchQuery" type="text" :placeholder="t('Search address')"
|
||||
class="bg-white dark:bg-gray-800 text-black dark:text-white absolute" />
|
||||
<UIcon name="ic:baseline-search"
|
||||
class="text-[20px] text-(--accent-blue-light) dark:text-(--accent-blue-dark) relative left-40" />
|
||||
</div>
|
||||
<UButton color="primary" @click="openCreateModal"
|
||||
class="bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white hover:bg-(--accent-blue-dark) dark:hover:bg-(--accent-blue-light)">
|
||||
<UIcon name="mdi:add-bold" />
|
||||
{{ t('Add Address') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="paginatedAddresses.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div v-for="address in paginatedAddresses" :key="address.id"
|
||||
class="border border-(--border-light) dark:border-(--border-dark) rounded-md p-4 bg-(--second-light) dark:bg-(--main-dark) hover:shadow-md transition-shadow">
|
||||
<p class="text-black dark:text-white">{{ address.street }}</p>
|
||||
<p class="text-black dark:text-white">{{ address.zipCode }}, {{ address.city }}</p>
|
||||
<p class="text-black dark:text-white">{{ address.country }}</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<UButton size="sm" color="neutral" variant="outline" @click="openEditModal(address)"
|
||||
class="text-(--accent-blue-light) dark:text-(--accent-blue-dark)">{{ t('edit') }}
|
||||
</UButton>
|
||||
<button size="sm" color="destructive" variant="outline" @click="confirmDelete(address.id)"
|
||||
class="text-red-500 hover:bg-red-100 dark:hover:bg-red-900 dark:hover:text-red-100 rounded transition-colors p-2">
|
||||
<UIcon name="material-symbols:delete" class="text-[16px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-gray-500 dark:text-gray-400">{{ t('No addresses found') }}</div>
|
||||
|
||||
<div class="mt-6 flex justify-center">
|
||||
<UPagination v-model:page="page" :total="totalItems" :page-size="pageSize" />
|
||||
</div>
|
||||
|
||||
<UModal v-model:open="showModal" :overlay="true" class="max-w-md mx-auto">
|
||||
<template #content>
|
||||
<div class="p-6 flex flex-col gap-6">
|
||||
<p class="text-[20px] text-black dark:text-white ">Address</p>
|
||||
<UForm @submit.prevent="saveAddress" class="space-y-4" :validate="validate">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black dark:text-white mb-1">Street *</label>
|
||||
<UInput v-model="formData.street" placeholder="Enter street" name="street" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black dark:text-white mb-1">Zip Code *</label>
|
||||
<UInput v-model="formData.zipCode" placeholder="Enter zip code" name="zipCode"
|
||||
class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black dark:text-white mb-1">City *</label>
|
||||
<UInput v-model="formData.city" placeholder="Enter city" name="city" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black dark:text-white mb-1">Country *</label>
|
||||
<UInput v-model="formData.country" placeholder="Enter country" name="country"
|
||||
class="w-full" />
|
||||
</div>
|
||||
</UForm>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton variant="outline" color="neutral" @click="closeModal"
|
||||
class="text-black dark:text-white">{{ t('Cancel') }}</UButton>
|
||||
<UButton variant="outline" color="neutral" @click="saveAddress"
|
||||
class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) hover:bg-(--accent-blue-dark) dark:hover:bg-(--accent-blue-light)">
|
||||
{{ t('Save') }}</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="showDeleteConfirm" :overlay="true" class="max-w-md mx-auto">
|
||||
<template #content>
|
||||
<div class="p-6 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2 justify-center items-center">
|
||||
<p class="flex items-end gap-2 dark:text-white text-black">
|
||||
<UIcon name='f7:exclamationmark-triangle' class="text-[35px] text-red-700" />
|
||||
Confirm Delete
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
{{ t('Are you sure you want to delete this address?') }}</p>
|
||||
</div>
|
||||
<div class="flex justify-center gap-5">
|
||||
<UButton variant="outline" color="neutral" @click="showDeleteConfirm = false"
|
||||
class="dark:text-white text-black">{{ t('Cancel') }}
|
||||
</UButton>
|
||||
<UButton variant="outline" color="neutral" @click="deleteAddress" class="text-red-700">
|
||||
{{ t('Delete') }}</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useAddressStore } from '@/stores/address'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const addressStore = useAddressStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const showModal = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingAddressId = ref<number | null>(null)
|
||||
const formData = ref({ street: '', zipCode: '', city: '', country: '' })
|
||||
|
||||
const showDeleteConfirm = ref(false)
|
||||
const addressToDelete = ref<number | null>(null)
|
||||
|
||||
const page = ref(addressStore.currentPage)
|
||||
const paginatedAddresses = computed(() => addressStore.paginatedAddresses)
|
||||
const totalItems = computed(() => addressStore.totalItems)
|
||||
const pageSize = addressStore.pageSize
|
||||
|
||||
watch(page, (newPage) => addressStore.setPage(newPage))
|
||||
|
||||
watch(searchQuery, (val) => {
|
||||
addressStore.setSearchQuery(val)
|
||||
})
|
||||
|
||||
function openCreateModal() {
|
||||
resetForm()
|
||||
isEditing.value = false
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(address: any) {
|
||||
formData.value = {
|
||||
street: address.street,
|
||||
zipCode: address.zipCode,
|
||||
city: address.city,
|
||||
country: address.country
|
||||
}
|
||||
isEditing.value = true
|
||||
editingAddressId.value = address.id
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.value = { street: '', zipCode: '', city: '', country: '' }
|
||||
editingAddressId.value = null
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const errors = []
|
||||
if (!formData.value.street) errors.push({ name: 'street', message: 'Street required' })
|
||||
if (!formData.value.zipCode) errors.push({ name: 'zipCode', message: 'Zip Code required' })
|
||||
if (!formData.value.city) errors.push({ name: 'city', message: 'City required' })
|
||||
if (!formData.value.country) errors.push({ name: 'country', message: 'Country required' })
|
||||
return errors.length ? errors : null
|
||||
}
|
||||
|
||||
function saveAddress() {
|
||||
if (validate()) return
|
||||
if (isEditing.value && editingAddressId.value) {
|
||||
addressStore.updateAddress(editingAddressId.value, formData.value)
|
||||
} else {
|
||||
addressStore.addAddress(formData.value)
|
||||
}
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
addressToDelete.value = id
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
function deleteAddress() {
|
||||
if (addressToDelete.value) {
|
||||
addressStore.deleteAddress(addressToDelete.value)
|
||||
}
|
||||
showDeleteConfirm.value = false
|
||||
addressToDelete.value = null
|
||||
}
|
||||
</script>
|
||||
218
bo/src/components/customer/PageCart.vue
Normal file
218
bo/src/components/customer/PageCart.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="container mx-auto mt-20 px-4 py-8">
|
||||
<h1 class="text-2xl font-bold text-black dark:text-white mb-8">{{ t('Shopping Cart') }}</h1>
|
||||
<div class="flex flex-col lg:flex-row gap-8 mb-8">
|
||||
<div class="flex-1">
|
||||
<div class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) overflow-hidden">
|
||||
<h2 class="text-lg font-semibold text-black dark:text-white p-4 border-b border-(--border-light) dark:border-(--border-dark)">
|
||||
{{ t('Selected Products') }}
|
||||
</h2>
|
||||
<div class="hidden md:grid grid-cols-12 gap-4 p-4 bg-(--second-light) dark:bg-(--main-dark) text-sm font-medium text-gray-600 dark:text-gray-300 border-b border-(--border-light) dark:border-(--border-dark)">
|
||||
<div class="col-span-4">{{ t('Product') }}</div>
|
||||
<div class="col-span-2 text-right">{{ t('Price') }}</div>
|
||||
<div class="col-span-3 text-center">{{ t('Quantity') }}</div>
|
||||
<div class="col-span-2 text-right">{{ t('Total') }}</div>
|
||||
<div class="col-span-1 text-center">{{ t('Actions') }}</div>
|
||||
</div>
|
||||
<div v-if="cartStore.items.length > 0">
|
||||
<div v-for="item in cartStore.items" :key="item.id"
|
||||
class="grid grid-cols-1 md:grid-cols-12 gap-4 p-4 border-b border-(--border-light) dark:border-(--border-dark) items-center">
|
||||
<div class="col-span-4 flex items-center gap-4">
|
||||
<div class="w-16 h-16 bg-(--second-light) dark:bg-(--main-dark) rounded flex items-center justify-center overflow-hidden">
|
||||
<img v-if="item.image" :src="item.image" :alt="item.name" class="w-full h-full object-cover" />
|
||||
<UIcon v-else name="mdi:package-variant" class="text-2xl text-gray-400" />
|
||||
</div>
|
||||
<span class="text-black dark:text-white text-sm font-medium">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="col-span-2 text-right">
|
||||
<span class="md:hidden text-gray-500 dark:text-gray-400 text-sm">{{ t('Price') }}: </span>
|
||||
<span class="text-black dark:text-white">${{ item.price.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="col-span-3 flex items-center justify-center">
|
||||
<div class="flex items-center border border-(--border-light) dark:border-(--border-dark) rounded">
|
||||
<button @click="decreaseQuantity(item)" class="px-3 py-1 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<UIcon name="mdi:minus" />
|
||||
</button>
|
||||
<span class="px-3 py-1 text-black dark:text-white min-w-[40px] text-center">{{ item.quantity }}</span>
|
||||
<button @click="increaseQuantity(item)" class="px-3 py-1 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<UIcon name="mdi:plus" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 text-right">
|
||||
<span class="md:hidden text-gray-500 dark:text-gray-400 text-sm">{{ t('Total') }}: </span>
|
||||
<span class="text-black dark:text-white font-medium">${{ (item.price * item.quantity).toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="col-span-1 flex justify-center">
|
||||
<button @click="removeItem(item.id)" class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors" :title="t('Remove')">
|
||||
<UIcon name="material-symbols:delete" class="text-[20px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-8 text-center">
|
||||
<UIcon name="mdi:cart-outline" class="text-6xl text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('Your cart is empty') }}</p>
|
||||
<RouterLink :to="{ name: 'product-card-full' }" class="inline-block mt-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark) hover:underline">
|
||||
{{ t('Continue Shopping') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:w-80">
|
||||
<div class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6 sticky top-24">
|
||||
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Order Summary') }}</h2>
|
||||
<div class="space-y-3 border-b border-(--border-light) dark:border-(--border-dark) pb-4 mb-4">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">{{ t('Products total') }}</span>
|
||||
<span class="text-black dark:text-white">${{ cartStore.productsTotal.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">{{ t('Shipping') }}</span>
|
||||
<span class="text-black dark:text-white">
|
||||
{{ cartStore.shippingCost > 0 ? `$${cartStore.shippingCost.toFixed(2)}` : t('Free') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">{{ t('VAT') }} ({{ (cartStore.vatRate * 100).toFixed(0) }}%)</span>
|
||||
<span class="text-black dark:text-white">${{ cartStore.vatAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-6">
|
||||
<span class="text-black dark:text-white font-semibold text-lg">{{ t('Total') }}</span>
|
||||
<span class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) font-bold text-lg">${{ cartStore.orderTotal.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<UButton block color="primary" @click="placeOrder" :disabled="!canPlaceOrder"
|
||||
class="bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white hover:bg-(--accent-blue-dark) dark:hover:bg-(--accent-blue-light) disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ t('Place Order') }}
|
||||
</UButton>
|
||||
<UButton block variant="outline" color="neutral" @click="cancelOrder"
|
||||
class="text-black dark:text-white border-(--border-light) dark:border-(--border-dark) hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
{{ t('Cancel') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<div class="flex-1">
|
||||
<div class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6">
|
||||
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Select Delivery Address') }}</h2>
|
||||
<div class="mb-4">
|
||||
<UInput v-model="addressSearchQuery" type="text" :placeholder="t('Search address')"
|
||||
class="w-full bg-white dark:bg-gray-800 text-black dark:text-white" />
|
||||
</div>
|
||||
<div v-if="addressStore.filteredAddresses.length > 0" class="space-y-3">
|
||||
<label v-for="address in addressStore.filteredAddresses" :key="address.id"
|
||||
class="flex items-start gap-3 p-4 border rounded-lg cursor-pointer transition-colors"
|
||||
:class="cartStore.selectedAddressId === address.id
|
||||
? 'border-(--accent-blue-light) dark:border-(--accent-blue-dark) bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-(--border-light) dark:border-(--border-dark) hover:border-gray-400'">
|
||||
<input type="radio" :value="address.id" v-model="selectedAddress"
|
||||
class="mt-1 w-4 h-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark)" />
|
||||
<div class="flex-1">
|
||||
<p class="text-black dark:text-white font-medium">{{ address.street }}</p>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">{{ address.zipCode }}, {{ address.city }}</p>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">{{ address.country }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="text-center py-6">
|
||||
<UIcon name="mdi:map-marker-outline" class="text-4xl text-gray-400 mb-2" />
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('No addresses found') }}</p>
|
||||
<RouterLink :to="{ name: 'addresses' }" class="inline-block mt-2 text-(--accent-blue-light) dark:text-(--accent-blue-dark) hover:underline">
|
||||
{{ t('Add Address') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6">
|
||||
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Delivery Method') }}</h2>
|
||||
<div class="space-y-3">
|
||||
<label v-for="method in cartStore.deliveryMethods" :key="method.id"
|
||||
class="flex items-center gap-3 p-4 border rounded-lg cursor-pointer transition-colors"
|
||||
:class="cartStore.selectedDeliveryMethodId === method.id
|
||||
? 'border-(--accent-blue-light) dark:border-(--accent-blue-dark) bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-(--border-light) dark:border-(--border-dark) hover:border-gray-400'">
|
||||
<input type="radio" :value="method.id" v-model="selectedDeliveryMethod"
|
||||
class="w-4 h-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark)" />
|
||||
<div class="flex-1">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-black dark:text-white font-medium">{{ method.name }}</span>
|
||||
<span class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) font-medium">
|
||||
{{ method.price > 0 ? `$${method.price.toFixed(2)}` : t('Free') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ method.description }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useCartStore, type CartItem } from '@/stores/cart'
|
||||
import { useAddressStore } from '@/stores/address'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const cartStore = useCartStore()
|
||||
const addressStore = useAddressStore()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const selectedAddress = ref<number | null>(cartStore.selectedAddressId)
|
||||
const selectedDeliveryMethod = ref<number | null>(cartStore.selectedDeliveryMethodId)
|
||||
const addressSearchQuery = ref('')
|
||||
|
||||
watch(addressSearchQuery, (val) => {
|
||||
addressStore.setSearchQuery(val)
|
||||
})
|
||||
|
||||
watch(selectedAddress, (newValue) => {
|
||||
cartStore.setSelectedAddress(newValue)
|
||||
})
|
||||
|
||||
watch(selectedDeliveryMethod, (newValue) => {
|
||||
if (newValue) {
|
||||
cartStore.setDeliveryMethod(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
const canPlaceOrder = computed(() => {
|
||||
return cartStore.items.length > 0 &&
|
||||
cartStore.selectedAddressId !== null &&
|
||||
cartStore.selectedDeliveryMethodId !== null
|
||||
})
|
||||
|
||||
function increaseQuantity(item: CartItem) {
|
||||
cartStore.updateQuantity(item.id, item.quantity + 1)
|
||||
}
|
||||
|
||||
function decreaseQuantity(item: CartItem) {
|
||||
cartStore.updateQuantity(item.id, item.quantity - 1)
|
||||
}
|
||||
|
||||
function removeItem(itemId: number) {
|
||||
cartStore.removeItem(itemId)
|
||||
}
|
||||
|
||||
function placeOrder() {
|
||||
if (canPlaceOrder.value) {
|
||||
console.log('Placing order...')
|
||||
alert(t('Order placed successfully!'))
|
||||
cartStore.clearCart()
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
}
|
||||
|
||||
function cancelOrder() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
164
bo/src/components/customer/PageProductCardFull.vue
Normal file
164
bo/src/components/customer/PageProductCardFull.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="container mt-14 mx-auto">
|
||||
<div class="flex justify-between gap-8 mb-6">
|
||||
<div class="flex-1">
|
||||
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-8 flex items-center justify-center min-h-[300px]">
|
||||
<img :src="selectedColor?.image || productData.image" :alt="productData.name"
|
||||
class="max-w-full h-auto object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ productData.name }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
{{ productData.description }}
|
||||
</p>
|
||||
<div class="text-3xl font-bold text-(--accent-blue-light) dark:text-(--accent-blue-dark)">
|
||||
{{ productData.price }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">Dimensions:</span>
|
||||
<p class="font-medium dark:text-white">{{ productData.dimensions }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">Seat Height:</span>
|
||||
<p class="font-medium dark:text-white">{{ productData.seatHeight }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-end mb-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm text-(--accent-blue-light) dark:text-(--accent-blue-dark) ">Colors:</span>
|
||||
<div class="flex gap-2">
|
||||
<button v-for="color in productData.colors" :key="color.id" @click="selectedColor = color"
|
||||
class="w-10 h-10 border-2 transition-all" :class="selectedColor?.id === color.id
|
||||
? 'border-(--accent-blue-light) ring-2 ring-blue-600 ring-offset-2'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'"
|
||||
:style="{ backgroundColor: color.hex }" :title="color.name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-5 items-end">
|
||||
<UInputNumber v-model="value" />
|
||||
<UButton color="primary" class="px-14! bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white">
|
||||
Add to Cart
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<ProductCustomization />
|
||||
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
|
||||
<div class="mb-6 w-[55%]">
|
||||
<div class="flex justify-between items-center gap-10 mb-8">
|
||||
<UButton v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
|
||||
'px-15 py-2 cursor-pointer',
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-600 hover:text-black hover:dark:text-white text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
]" variant="ghost">
|
||||
{{ tab.label }}
|
||||
</UButton>
|
||||
</div>
|
||||
<div class="py-5 px-3 bg-(--second-light) dark:bg-(--main-dark) rounded-md">
|
||||
<p class="dark:text-white whitespace-pre-line">
|
||||
{{ activeTabContent }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
|
||||
<ProductVariants />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import ProductCustomization from './components/ProductCustomization.vue'
|
||||
import ProductVariants from './components/ProductVariants.vue'
|
||||
|
||||
interface Color {
|
||||
id: string
|
||||
name: string
|
||||
hex: string
|
||||
image: string
|
||||
}
|
||||
|
||||
interface ProductData {
|
||||
name: string
|
||||
description: string
|
||||
price: string
|
||||
dimensions: string
|
||||
seatHeight: string
|
||||
image: string
|
||||
colors: Color[]
|
||||
descriptionText: string
|
||||
howToUseText: string
|
||||
productDetailsText: string
|
||||
documentsText: string
|
||||
}
|
||||
|
||||
const activeTab = ref('description')
|
||||
const value = ref(5)
|
||||
const selectedColor = ref<Color | null>(null)
|
||||
|
||||
const productData: ProductData = {
|
||||
name: 'Larger Corner Sofa',
|
||||
description: 'The large corner sofa is a comfortable seating solution for young children. It is upholstered with phthalate-free PVC-coated material intended for medical products, making it very easy to clean and disinfect. The product is available in a wide range of colours (13 colours), allowing it to fit into any interior.',
|
||||
price: 'PLN 519.00 (VAT 23%)',
|
||||
dimensions: '65 x 65 x 120 cm',
|
||||
seatHeight: '45-55 cm',
|
||||
image: '/placeholder-chair.jpg',
|
||||
colors: [
|
||||
{ id: 'black', name: 'Black', hex: '#1a1a1a', image: '/chair-black.jpg' },
|
||||
{ id: 'gray', name: 'Gray', hex: '#6b7280', image: '/chair-gray.jpg' },
|
||||
{ id: 'blue', name: 'Blue', hex: '#3b82f6', image: '/chair-blue.jpg' },
|
||||
{ id: 'brown', name: 'Brown', hex: '#92400e', image: '/chair-brown.jpg' },
|
||||
],
|
||||
descriptionText: 'The large corner sofa is a comfortable seating solution for young children. It is upholstered with phthalate-free PVC-coated material intended for medical products, making it very easy to clean and disinfect. The product is available in a wide range of colours (13 colours), allowing it to fit into any interior',
|
||||
howToUseText: '1. Adjust the seat height using the lever under the seat.\n2. Set the lumbar support to your preferred position.\n3. Adjust the armrests for optimal arm support.\n4. Use the recline tension knob to adjust the backrest resistance.\n5. Lock the recline position when needed.',
|
||||
productDetailsText: '• Material: Mesh, Foam, Plastic\n• Max Load: 150 kg\n• Weight: 18 kg\n• Warranty: 2 years\n• Certifications: BIFMA, EN 1335',
|
||||
documentsText: '• Assembly Instructions (PDF)\n• User Manual (PDF)\n• Warranty Terms (PDF)\n• Safety Certificate (PDF)',
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'description', label: 'Description' },
|
||||
{ id: 'howToUse', label: 'How to Use' },
|
||||
{ id: 'productDetails', label: 'Product Details' },
|
||||
{ id: 'documents', label: 'Documents' },
|
||||
]
|
||||
|
||||
const userActions = [
|
||||
'View detailed product information',
|
||||
'Browse product images and available colors',
|
||||
'Check product dimensions and specifications',
|
||||
'Select a product variant',
|
||||
'Select quantity',
|
||||
'Add the product to the cart',
|
||||
'Navigate between product description, usage instructions, and product details',
|
||||
]
|
||||
|
||||
const activeTabContent = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'description':
|
||||
return productData.descriptionText
|
||||
case 'howToUse':
|
||||
return productData.howToUseText
|
||||
case 'productDetails':
|
||||
return productData.productDetailsText
|
||||
case 'documents':
|
||||
return productData.documentsText
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
if (productData.colors.length > 0) {
|
||||
selectedColor.value = productData.colors[0] as Color
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-card-full {
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="container flex flex-col gap-8">
|
||||
<div class="space-y-1 dark:text-white text-black">
|
||||
<p class="text-[24px] font-bold">Product customization</p>
|
||||
<p class="text-[15px]">Don't forget to save your customization to be able to add to cart</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-10">
|
||||
<UInput label="Podaj kolor kanapy narożnej" placeholder="Podaj kolor kanapy narożnej" class="dark:text-white text-black"/>
|
||||
<UInput label="Podaj kolor fotela" placeholder="Podaj kolor fotela" class="dark:text-white text-black"/>
|
||||
<UInput label="Podaj kolor kwadratu" placeholder="Podaj kolor kwadratu" class="dark:text-white text-black"/>
|
||||
</div>
|
||||
<div class="flex justify-end items-end mb-8">
|
||||
<UButton class="px-10! bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white">Save</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
57
bo/src/components/customer/components/ProductVariants.vue
Normal file
57
bo/src/components/customer/components/ProductVariants.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="container flex flex-col gap-8">
|
||||
<p class="text-[24px] font-bold dark:text-white text-black">Product Variants:</p>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="(variant, index) in variants" :key="index" class="flex gap-10">
|
||||
<div
|
||||
class="flex items-center gap-15 border border-(--border-light) dark:border-(--border-dark) p-5 rounded-md hover:bg-gray-50 hover:dark:bg-gray-700 bg-(--second-light) dark:bg-(--second-dark) dark:text-white text-black">
|
||||
<img :src="variant.image" :alt="variant.image" class="w-16 h-16 object-cover" />
|
||||
<p class="">{{ variant.name }}</p>
|
||||
<p class="">{{ variant.productNumber }}</p>
|
||||
<p class="">{{ variant.value }}</p>
|
||||
<p class="">{{ variant.price }}</p>
|
||||
<p class="">{{ variant.quantity }}</p>
|
||||
<button class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
|
||||
@click="addToCart(variant)">
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const variants = ref([
|
||||
{
|
||||
image: 'img',
|
||||
name: 'Duży fotelik narożny ',
|
||||
productNumber: 'NC209/7000',
|
||||
value: '20,000',
|
||||
price: '519,00 zł',
|
||||
quantity: 10
|
||||
},
|
||||
{
|
||||
image: 'img',
|
||||
name: 'Duży fotelik narożny ',
|
||||
productNumber: 'NC209/7000',
|
||||
value: '20,000',
|
||||
price: '519,00 zł',
|
||||
quantity: 5
|
||||
},
|
||||
{
|
||||
image: 'img',
|
||||
name: 'Duży fotelik narożny ',
|
||||
productNumber: 'NC209/7000',
|
||||
value: '20,000',
|
||||
price: '519,00 zł',
|
||||
quantity: 8
|
||||
}
|
||||
])
|
||||
|
||||
const addToCart = (variant: any) => {
|
||||
console.log('Added to cart:', variant)
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<TopBar/>
|
||||
<router-view />
|
||||
</main>
|
||||
<main class="p-10">
|
||||
<div class="mt-24 bg-accented w-1/5 rounded-2xl">
|
||||
<UNavigationMenu orientation="vertical" type="single" :items="items" class="p-4">
|
||||
</UNavigationMenu>
|
||||
|
||||
</div>
|
||||
<!-- <template #item>{{ item }}</template> -->
|
||||
|
||||
|
||||
<TopBar/>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<main :key="i18n.locale.value">
|
||||
<TopBarLogin />
|
||||
<router-view />
|
||||
<slot></slot>
|
||||
</main>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Empty from '@/layouts/empty.vue'
|
||||
import { currentLang, langs } from './langs'
|
||||
import { getSettings } from './settings'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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 {
|
||||
if (typeof document === 'undefined') return false
|
||||
return document.cookie.split('; ').some((c) => c === 'is_authenticated=1')
|
||||
@@ -14,6 +12,7 @@ function isAuthenticated(): boolean {
|
||||
|
||||
|
||||
await getSettings()
|
||||
// await getMenu()
|
||||
|
||||
|
||||
const router = createRouter({
|
||||
@@ -26,64 +25,101 @@ const router = createRouter({
|
||||
{
|
||||
path: '/:locale',
|
||||
children: [
|
||||
{ path: 'category/:category_id-:link_rewrite', component: () => import('../views/CategoryView.vue'), name: 'category' },
|
||||
{
|
||||
path: '',
|
||||
component: Default,
|
||||
children: [
|
||||
{ path: '', component: () => import('../views/RepoChartView.vue'), name: 'home' },
|
||||
{ path: 'products', component: () => import('../views/customer/ProductsView.vue'), name: 'products' },
|
||||
{ path: 'products-datail/', component: () => import('../views/customer/ProductDetailView.vue'), name: 'product-detail' },
|
||||
{ path: 'products', component: () => import('../components/admin/ProductsView.vue'), name: 'products' },
|
||||
{ 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: '',
|
||||
component: Empty,
|
||||
children: [
|
||||
{ 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: () => import('@/views/NotFoundView.vue'),
|
||||
name: 'not-found',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Navigation guard: language handling + auth protection
|
||||
router.beforeEach((to, from, next) => {
|
||||
const locale = to.params.locale as string
|
||||
const localeLang = langs.find((x) => x.iso_code == locale)
|
||||
// router.beforeEach((to, from, next) => {
|
||||
// const locale = to.params.locale as string
|
||||
// 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) {
|
||||
const authStore = useAuthStore()
|
||||
console.log(authStore.isAuthenticated,to, from)
|
||||
// if()
|
||||
const validLocale = langs.find((l) => l.lang_code === locale)
|
||||
|
||||
if (validLocale) {
|
||||
currentLang.value = localeLang
|
||||
|
||||
// Auth guard: if the route does NOT have meta.guest = true, require authentication
|
||||
if (!to.meta?.guest && !isAuthenticated()) {
|
||||
return next({ name: 'login', params: { locale } })
|
||||
if (!to.meta?.guest && !authStore.isAuthenticated) {
|
||||
return { name: 'login', params: { locale } }
|
||||
}
|
||||
|
||||
return next()
|
||||
return true
|
||||
} else if (locale) {
|
||||
// Invalid locale - redirect to default language
|
||||
return next(`/${currentLang.value?.iso_code}${to.path.replace(`/${locale}`, '') || '/'}`)
|
||||
return `/${currentLang.value?.iso_code}${to.path.replace(`/${locale}`, '') || '/'}`
|
||||
}
|
||||
}
|
||||
|
||||
// No locale in URL - redirect to default language
|
||||
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
|
||||
|
||||
8
bo/src/router/menu.ts
Normal file
8
bo/src/router/menu.ts
Normal 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
145
bo/src/stores/address.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface AddressFormData {
|
||||
street: string
|
||||
zipCode: string
|
||||
city: string
|
||||
country: string
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
id: number
|
||||
street: string
|
||||
zipCode: string
|
||||
city: string
|
||||
country: string
|
||||
}
|
||||
|
||||
export const useAddressStore = defineStore('address', () => {
|
||||
const addresses = ref<Address[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
function initMockData() {
|
||||
addresses.value = [
|
||||
{ id: 1, street: 'Main Street 123', zipCode: '10-001', city: 'New York', country: 'United States' },
|
||||
{ id: 2, street: 'Oak Avenue 123', zipCode: '90-001', city: 'Los Angeles', country: 'United States' },
|
||||
{ id: 3, street: 'Pine Road 123', zipCode: '60-601', city: 'Chicago', country: 'United States' }
|
||||
]
|
||||
}
|
||||
|
||||
const filteredAddresses = computed(() => {
|
||||
if (!searchQuery.value) return addresses.value
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
|
||||
return addresses.value.filter(addr =>
|
||||
addr.street.toLowerCase().includes(query) ||
|
||||
addr.city.toLowerCase().includes(query) ||
|
||||
addr.country.toLowerCase().includes(query) ||
|
||||
addr.zipCode.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
const totalItems = computed(() => filteredAddresses.value.length)
|
||||
const totalPages = computed(() => Math.ceil(totalItems.value / pageSize))
|
||||
|
||||
const paginatedAddresses = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize
|
||||
return filteredAddresses.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
function getAddressById(id: number) {
|
||||
return addresses.value.find(addr => addr.id === id)
|
||||
}
|
||||
|
||||
function normalize(data: AddressFormData): AddressFormData {
|
||||
return {
|
||||
street: data.street.trim(),
|
||||
zipCode: data.zipCode.trim(),
|
||||
city: data.city.trim(),
|
||||
country: data.country.trim()
|
||||
}
|
||||
}
|
||||
|
||||
function generateId(): number {
|
||||
return Math.max(0, ...addresses.value.map(a => a.id)) + 1
|
||||
}
|
||||
|
||||
function addAddress(formData: AddressFormData): Address {
|
||||
const newAddress: Address = {
|
||||
id: generateId(),
|
||||
...normalize(formData)
|
||||
}
|
||||
|
||||
addresses.value.unshift(newAddress)
|
||||
resetPagination()
|
||||
|
||||
return newAddress
|
||||
}
|
||||
|
||||
function updateAddress(id: number, formData: AddressFormData): boolean {
|
||||
const index = addresses.value.findIndex(a => a.id === id)
|
||||
if (index === -1) return false
|
||||
|
||||
const existing = addresses.value[index]
|
||||
if (!existing) return false
|
||||
|
||||
addresses.value[index] = {
|
||||
id: existing.id,
|
||||
...normalize(formData)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
function deleteAddress(id: number): boolean {
|
||||
const index = addresses.value.findIndex(a => a.id === id)
|
||||
if (index === -1) return false
|
||||
|
||||
addresses.value.splice(index, 1)
|
||||
resetPagination()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function setPage(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
function setSearchQuery(query: string) {
|
||||
searchQuery.value = query
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function resetPagination() {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
initMockData()
|
||||
|
||||
return {
|
||||
addresses,
|
||||
loading,
|
||||
error,
|
||||
currentPage,
|
||||
pageSize,
|
||||
totalItems,
|
||||
totalPages,
|
||||
searchQuery,
|
||||
filteredAddresses,
|
||||
paginatedAddresses,
|
||||
getAddressById,
|
||||
addAddress,
|
||||
updateAddress,
|
||||
deleteAddress,
|
||||
setPage,
|
||||
setSearchQuery,
|
||||
resetPagination
|
||||
}
|
||||
})
|
||||
113
bo/src/stores/cart.ts
Normal file
113
bo/src/stores/cart.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface CartItem {
|
||||
id: number
|
||||
productId: number
|
||||
name: string
|
||||
image: string
|
||||
price: number
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export interface DeliveryMethod {
|
||||
id: number
|
||||
name: string
|
||||
price: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export const useCartStore = defineStore('cart', () => {
|
||||
const items = ref<CartItem[]>([])
|
||||
const selectedAddressId = ref<number | null>(null)
|
||||
const selectedDeliveryMethodId = ref<number | null>(null)
|
||||
const shippingCost = ref(0)
|
||||
const vatRate = ref(0.23) // 23% VAT
|
||||
|
||||
const deliveryMethods = ref<DeliveryMethod[]>([
|
||||
{ id: 1, name: 'Standard Delivery', price: 0, description: '5-7 business days' },
|
||||
{ id: 2, name: 'Express Delivery', price: 15, description: '2-3 business days' },
|
||||
{ id: 3, name: 'Priority Delivery', price: 30, description: 'Next business day' }
|
||||
])
|
||||
|
||||
function initMockData() {
|
||||
items.value = [
|
||||
{ id: 1, productId: 101, name: 'Premium Widget Pro', image: '/img/product-1.jpg', price: 129.99, quantity: 2 },
|
||||
{ id: 2, productId: 102, name: 'Ultra Gadget X', image: '/img/product-2.jpg', price: 89.50, quantity: 1 },
|
||||
{ id: 3, productId: 103, name: 'Mega Tool Set', image: '/img/product-3.jpg', price: 249.00, quantity: 3 }
|
||||
]
|
||||
}
|
||||
|
||||
const productsTotal = computed(() => {
|
||||
return items.value.reduce((sum, item) => sum + (item.price * item.quantity), 0)
|
||||
})
|
||||
|
||||
const vatAmount = computed(() => {
|
||||
return productsTotal.value * vatRate.value
|
||||
})
|
||||
|
||||
const orderTotal = computed(() => {
|
||||
return productsTotal.value + shippingCost.value + vatAmount.value
|
||||
})
|
||||
|
||||
const itemCount = computed(() => {
|
||||
return items.value.reduce((sum, item) => sum + item.quantity, 0)
|
||||
})
|
||||
|
||||
function updateQuantity(itemId: number, quantity: number) {
|
||||
const item = items.value.find(i => i.id === itemId)
|
||||
if (item) {
|
||||
if (quantity <= 0) {
|
||||
removeItem(itemId)
|
||||
} else {
|
||||
item.quantity = quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeItem(itemId: number) {
|
||||
const index = items.value.findIndex(i => i.id === itemId)
|
||||
if (index !== -1) {
|
||||
items.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
items.value = []
|
||||
selectedAddressId.value = null
|
||||
selectedDeliveryMethodId.value = null
|
||||
shippingCost.value = 0
|
||||
}
|
||||
|
||||
function setSelectedAddress(addressId: number | null) {
|
||||
selectedAddressId.value = addressId
|
||||
}
|
||||
|
||||
function setDeliveryMethod(methodId: number) {
|
||||
selectedDeliveryMethodId.value = methodId
|
||||
const method = deliveryMethods.value.find(m => m.id === methodId)
|
||||
if (method) {
|
||||
shippingCost.value = method.price
|
||||
}
|
||||
}
|
||||
|
||||
initMockData()
|
||||
|
||||
return {
|
||||
items,
|
||||
selectedAddressId,
|
||||
selectedDeliveryMethodId,
|
||||
shippingCost,
|
||||
vatRate,
|
||||
deliveryMethods,
|
||||
productsTotal,
|
||||
vatAmount,
|
||||
orderTotal,
|
||||
itemCount,
|
||||
updateQuantity,
|
||||
removeItem,
|
||||
clearCart,
|
||||
setSelectedAddress,
|
||||
setDeliveryMethod
|
||||
}
|
||||
})
|
||||
21
bo/src/views/CategoryView.vue
Normal file
21
bo/src/views/CategoryView.vue
Normal 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>
|
||||
@@ -4,8 +4,8 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useValidation } from '@/composable/useValidation'
|
||||
import type { FormError } from '@nuxt/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { i18n } from '@/plugins/02_i18n'
|
||||
import Empty from '@/layouts/empty.vue'
|
||||
|
||||
const router = useRouter()
|
||||
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')),
|
||||
),
|
||||
)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="Empty || 'div'">
|
||||
<UDrawer v-model:open="showTherms" :overlay="false">
|
||||
<template #body>
|
||||
<component :is="TermsComponent" />
|
||||
@@ -163,4 +165,5 @@ const PrivacyComponent = computed(() =>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
7
bo/src/views/NotFoundView.vue
Normal file
7
bo/src/views/NotFoundView.vue
Normal 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>
|
||||
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useValidation } from '@/composable/useValidation'
|
||||
import type { FormError } from '@nuxt/ui'
|
||||
import { i18n } from '@/plugins/02_i18n'
|
||||
import Empty from '@/layouts/empty.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -36,70 +37,74 @@ function validate(): FormError[] {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[100vh] flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-15">
|
||||
<div
|
||||
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" />
|
||||
<component :is="Empty || 'div'">
|
||||
<div class="h-[100vh] flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-15">
|
||||
<div
|
||||
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>
|
||||
<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">
|
||||
<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-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>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('general.password_reset_link_sent_notice') }}
|
||||
</p>
|
||||
<UButton color="neutral" variant="outline" block @click="goToLogin"
|
||||
class="dark:text-white text-black cursor-pointer">
|
||||
{{ $t('general.back_to_sign_in') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="submitted">
|
||||
<div class="text-center flex flex-col gap-4">
|
||||
<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>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('general.password_reset_link_sent_notice') }}
|
||||
</p>
|
||||
<UButton color="neutral" variant="outline" block @click="goToLogin"
|
||||
class="dark:text-white text-black cursor-pointer">
|
||||
{{ $t('general.back_to_sign_in') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('general.enter_email_for_password_reset') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('general.enter_email_for_password_reset') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
:title="authStore.error" :close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }"
|
||||
@close="authStore.clearError" />
|
||||
<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"
|
||||
:title="authStore.error" :close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }"
|
||||
@close="authStore.clearError" />
|
||||
|
||||
<UFormField :label="$t('general.email_address')" name="email" required
|
||||
class="w-full dark:text-white text-black">
|
||||
<UInput v-model="email" :placeholder="$t('general.enter_your_email')" :disabled="authStore.loading"
|
||||
class="w-full dark:text-white text-black placeholder:text-(--placeholder)" />
|
||||
</UFormField>
|
||||
<UFormField :label="$t('general.email_address')" name="email" required
|
||||
class="w-full dark:text-white text-black">
|
||||
<UInput v-model="email" :placeholder="$t('general.enter_your_email')" :disabled="authStore.loading"
|
||||
class="w-full dark:text-white text-black placeholder:text-(--placeholder)" />
|
||||
</UFormField>
|
||||
|
||||
<UButton type="submit" block :loading="authStore.loading"
|
||||
class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) cursor-pointer">
|
||||
{{ $t('general.send_password_reset_link') }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
<UButton type="submit" block :loading="authStore.loading"
|
||||
class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) cursor-pointer">
|
||||
{{ $t('general.send_password_reset_link') }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
|
||||
<div class="text-center flex flex-col gap-3 border-t dark:border-(--border-dark) border-(--border-light) pt-4">
|
||||
<button color="neutral" variant="outline" :loading="authStore.loading"
|
||||
class="w-full flex items-center gap-2 justify-center text-[15px] dark:text-white text-black cursor-pointer"
|
||||
@click="goToLogin">
|
||||
<UIcon name="mingcute:arrow-left-line" class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) text-[16px]" />
|
||||
{{ $t('general.back_to_sign_in') }}
|
||||
</button>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $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') }}
|
||||
<div
|
||||
class="text-center flex flex-col gap-3 border-t dark:border-(--border-dark) border-(--border-light) pt-4">
|
||||
<button color="neutral" variant="outline" :loading="authStore.loading"
|
||||
class="w-full flex items-center gap-2 justify-center text-[15px] dark:text-white text-black cursor-pointer"
|
||||
@click="goToLogin">
|
||||
<UIcon name="mingcute:arrow-left-line"
|
||||
class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) text-[16px]" />
|
||||
{{ $t('general.back_to_sign_in') }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $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>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<component :is="Empty || 'div'">
|
||||
<UDrawer v-model:open="showTherms" :overlay="false">
|
||||
<template #body>
|
||||
<component :is="TermsComponent" />
|
||||
@@ -110,6 +111,7 @@
|
||||
</UForm>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -120,6 +122,8 @@ import { useValidation } from '@/composable/useValidation'
|
||||
import type { FormError } from '@nuxt/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { i18n } from '@/plugins/02_i18n'
|
||||
import Empty from '@/layouts/empty.vue'
|
||||
|
||||
|
||||
const { locale } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -15,8 +15,6 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { i18n } from '@/plugins/02_i18n'
|
||||
import type { TableColumn } from '@nuxt/ui'
|
||||
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)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useValidation } from '@/composable/useValidation'
|
||||
import type { FormError } from '@nuxt/ui'
|
||||
import { i18n } from '@/plugins/02_i18n'
|
||||
import Empty from '@/layouts/empty.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -49,80 +50,84 @@ function validate(): FormError[] {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[100vh] flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-15">
|
||||
<div
|
||||
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 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>
|
||||
<component :is="Empty || 'div'">
|
||||
<div class="h-[100vh] flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-15">
|
||||
<div
|
||||
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>
|
||||
</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>
|
||||
<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" />
|
||||
<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') }}
|
||||
</button>
|
||||
</UButton>
|
||||
</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>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFetchJson } from '@/composable/useFetchJson'
|
||||
import { i18n } from '@/plugins/02_i18n'
|
||||
import Empty from '@/layouts/empty.vue'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -66,81 +67,84 @@ function goToLogin() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 class="pt-20 pb-8 flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div
|
||||
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" />
|
||||
<component :is="Empty || 'div'">
|
||||
<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 class="pt-20 pb-8 flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
2
go.mod
2
go.mod
@@ -11,7 +11,9 @@ require (
|
||||
github.com/gofiber/fiber/v3 v3.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.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/samber/lo v1.53.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
google.golang.org/api v0.247.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -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-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/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/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
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/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
|
||||
|
||||
@@ -6,16 +6,16 @@ CREATE TABLE IF NOT EXISTS b2b_tracker_routes (
|
||||
path VARCHAR(255) NULL,
|
||||
component VARCHAR(255) NOT NULL COMMENT 'path to component file',
|
||||
layout VARCHAR(50) DEFAULT 'default' COMMENT "'default' | 'empty'",
|
||||
meta JSON DEFAULT '{}' ,
|
||||
meta JSON DEFAULT '{}',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INT DEFAULT 0,
|
||||
parent_id INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
parent_id INT NULL,
|
||||
|
||||
ALTER TABLE b2b_tracker_routes
|
||||
ADD CONSTRAINT fk_parent
|
||||
FOREIGN KEY (parent_id) REFERENCES b2b_tracker_routes(id)
|
||||
ON DELETE SET NULL;
|
||||
CONSTRAINT fk_parent
|
||||
FOREIGN KEY (parent_id)
|
||||
REFERENCES b2b_tracker_routes(id)
|
||||
ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
INSERT IGNORE INTO b2b_tracker_routes
|
||||
(name, path, component, layout, meta, is_active, sort_order, parent_id)
|
||||
|
||||
@@ -24,7 +24,8 @@ INSERT IGNORE INTO b2b_language
|
||||
VALUES
|
||||
(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, '🇬🇧'),
|
||||
(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 (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -71,7 +72,8 @@ CREATE TABLE IF NOT EXISTS b2b_customers (
|
||||
password_reset_expires DATETIME(6) NULL,
|
||||
last_password_reset_request 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,
|
||||
updated_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);
|
||||
|
||||
|
||||
-- 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
|
||||
(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);
|
||||
ALTER TABLE b2b_customers AUTO_INCREMENT = 1;
|
||||
(1, 'Polska', 1, '🇵🇱'),
|
||||
(2, 'England', 2, '🇬🇧'),
|
||||
(3, 'Čeština', 2, '🇨🇿'),
|
||||
(4, 'Deutschland', 2, '🇩🇪');
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP TABLE IF EXISTS b2b_countries;
|
||||
DROP TABLE IF EXISTS b2b_language;
|
||||
DROP TABLE IF EXISTS b2b_components;
|
||||
DROP TABLE IF EXISTS b2b_scopes;
|
||||
|
||||
431
package-lock.json
generated
431
package-lock.json
generated
@@ -4,6 +4,7 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "b2b",
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^4.5.1",
|
||||
"chart.js": "^4.5.1",
|
||||
@@ -1006,6 +1007,395 @@
|
||||
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -1975,13 +2365,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@unhead/vue": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.10.tgz",
|
||||
"integrity": "sha512-VP78Onh2HNezLPfhYjfHqn4dxlcQsE6PJgTTs61NksO/thvilNswtgBq0N0MWCLtn43N5akEPGW2y2zxM3PWgQ==",
|
||||
"version": "2.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.12.tgz",
|
||||
"integrity": "sha512-zEWqg0nZM8acpuTZE40wkeUl8AhIe0tU0OkilVi1D4fmVjACrwoh5HP6aNqJ8kUnKsoy6D+R3Vi/O+fmdNGO7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hookable": "^6.0.1",
|
||||
"unhead": "2.1.10"
|
||||
"unhead": "2.1.12"
|
||||
},
|
||||
"funding": {
|
||||
"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": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
|
||||
@@ -2846,9 +3251,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/h3": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.5.tgz",
|
||||
"integrity": "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==",
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.8.tgz",
|
||||
"integrity": "sha512-iOH6Vl8mGd9nNfu9C0IZ+GuOAfJHcyf3VriQxWaSWIB76Fg4BnFuk4cxBxjmQSSxJS664+pgjP6e7VBnUzFfcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-es": "^1.2.2",
|
||||
@@ -4257,9 +4662,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unhead": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.10.tgz",
|
||||
"integrity": "sha512-We8l9uNF8zz6U8lfQaVG70+R/QBfQx1oPIgXin4BtZnK2IQpz6yazQ0qjMNVBDw2ADgF2ea58BtvSK+XX5AS7g==",
|
||||
"version": "2.1.12",
|
||||
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz",
|
||||
"integrity": "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hookable": "^6.0.1"
|
||||
@@ -4269,9 +4674,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unhead/node_modules/hookable": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-6.0.1.tgz",
|
||||
"integrity": "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz",
|
||||
"integrity": "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unifont": {
|
||||
|
||||
Reference in New Issue
Block a user