Compare commits
3 Commits
8c4e664ca8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 378baf4458 | |||
| d55b0e2914 | |||
| 1b53c1c199 |
@@ -6,7 +6,8 @@ ASSET_MANIFEST_PATH=web/dist/manifest.json
|
|||||||
# Public shop URL and upstream proxy target
|
# Public shop URL and upstream proxy target
|
||||||
PRESTASHOP_BASE_URL=http://localhost
|
PRESTASHOP_BASE_URL=http://localhost
|
||||||
PRESTASHOP_PROXY_TARGET=http://localhost
|
PRESTASHOP_PROXY_TARGET=http://localhost
|
||||||
PRESTASHOP_VERSION=1.7.2
|
PRESTASHOP_VERSION=1.7.3
|
||||||
|
|
||||||
|
|
||||||
# Cookie settings
|
# Cookie settings
|
||||||
# Optional explicit override. If omitted, the app derives the cookie name from
|
# Optional explicit override. If omitted, the app derives the cookie name from
|
||||||
@@ -14,7 +15,7 @@ PRESTASHOP_VERSION=1.7.2
|
|||||||
# PRESTASHOP_COOKIE_NAME=
|
# PRESTASHOP_COOKIE_NAME=
|
||||||
PRESTASHOP_COOKIE_KEY=def00000cecd7a19e52c6ae0ca758f54dd6e682c8fe4c657b8441974a33c6d11a0fc238a02c0f2de4a46fed7a57e2db8d6f6c4c615a937a26af5163293ae6702bc5d18f4
|
PRESTASHOP_COOKIE_KEY=def00000cecd7a19e52c6ae0ca758f54dd6e682c8fe4c657b8441974a33c6d11a0fc238a02c0f2de4a46fed7a57e2db8d6f6c4c615a937a26af5163293ae6702bc5d18f4
|
||||||
PRESTASHOP_COOKIE_IV=vfRFMV42
|
PRESTASHOP_COOKIE_IV=vfRFMV42
|
||||||
|
DOMAIN_COOKIE=marek.ma-al.pl
|
||||||
# PrestaShop DB
|
# PrestaShop DB
|
||||||
PRESTASHOP_DB_DIALECT=mariadb
|
PRESTASHOP_DB_DIALECT=mariadb
|
||||||
DB_USER=presta
|
DB_USER=presta
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ Go reverse proxy in front of PrestaShop `1.7.3` with:
|
|||||||
## Current scope
|
## Current scope
|
||||||
|
|
||||||
- Go owns `GET /product/:slug`
|
- Go owns `GET /product/:slug`
|
||||||
|
- Go owns `GET /cart`
|
||||||
- all other routes proxy to the upstream PrestaShop instance
|
- all other routes proxy to the upstream PrestaShop instance
|
||||||
- product data, customer data, and cart summary are read from the PrestaShop database
|
- product data, customer data, and cart summary are read from the PrestaShop database
|
||||||
|
- cart add/update/delete writes go directly to the PrestaShop database and keep the PrestaShop cookie in sync
|
||||||
- session state is derived from the live PrestaShop cookie
|
- session state is derived from the live PrestaShop cookie
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -30,6 +32,7 @@ The service now loads `.env` automatically from the project root at startup.
|
|||||||
Important variables:
|
Important variables:
|
||||||
|
|
||||||
- `PRESTASHOP_PROXY_TARGET`: upstream PrestaShop origin, required
|
- `PRESTASHOP_PROXY_TARGET`: upstream PrestaShop origin, required
|
||||||
|
- `DOMAIN_COOKIE`: optional domain override used when deriving the hashed `PrestaShop-...` cookie name
|
||||||
- `PRESTASHOP_COOKIE_NAME`: optional explicit cookie-name override. If omitted, the app derives the standard `PrestaShop-...` name from PrestaShop version and normalized host, and still falls back to prefix matching on reads.
|
- `PRESTASHOP_COOKIE_NAME`: optional explicit cookie-name override. If omitted, the app derives the standard `PrestaShop-...` name from PrestaShop version and normalized host, and still falls back to prefix matching on reads.
|
||||||
- `PRESTASHOP_COOKIE_KEY`: Defuse/PrestaShop cookie key, required unless bootstrap from install root is used
|
- `PRESTASHOP_COOKIE_KEY`: Defuse/PrestaShop cookie key, required unless bootstrap from install root is used
|
||||||
- `DB_USER`, `DB_PASS`, `DB_NAME`, `DB_HOST`, `DB_PORT`: preferred split MariaDB settings
|
- `DB_USER`, `DB_PASS`, `DB_NAME`, `DB_HOST`, `DB_PORT`: preferred split MariaDB settings
|
||||||
@@ -89,6 +92,18 @@ Default listen address is `:8080`.
|
|||||||
- `GET /healthz`
|
- `GET /healthz`
|
||||||
- `GET /readyz`
|
- `GET /readyz`
|
||||||
|
|
||||||
|
## Debug endpoint
|
||||||
|
|
||||||
|
- `GET|POST /debug/cookie/decode`
|
||||||
|
|
||||||
|
Pass a cookie explicitly with `value` or `cookie`, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8080/debug/cookie/decode?value=def50200..."
|
||||||
|
```
|
||||||
|
|
||||||
|
If no parameter is provided, the endpoint returns the cookie already decoded from the incoming request session.
|
||||||
|
|
||||||
## Cookie support
|
## Cookie support
|
||||||
|
|
||||||
Native cookie logic lives in [internal/prestashop/cookie/codec.go](/home/marek/coding/test/pp/internal/prestashop/cookie/codec.go:1).
|
Native cookie logic lives in [internal/prestashop/cookie/codec.go](/home/marek/coding/test/pp/internal/prestashop/cookie/codec.go:1).
|
||||||
|
|||||||
+23
-1
@@ -71,7 +71,13 @@ func run() error {
|
|||||||
customerService := pscustomer.NewService(prestaDB, cfg.PrestaShopTablePrefix)
|
customerService := pscustomer.NewService(prestaDB, cfg.PrestaShopTablePrefix)
|
||||||
cartService := pscart.NewService(prestaDB, cfg.PrestaShopTablePrefix)
|
cartService := pscart.NewService(prestaDB, cfg.PrestaShopTablePrefix)
|
||||||
routeService := psroutes.NewService(prestaDB, cfg.PrestaShopTablePrefix)
|
routeService := psroutes.NewService(prestaDB, cfg.PrestaShopTablePrefix)
|
||||||
sessionService := pssession.NewService(prestaDB, cfg.PrestaShopTablePrefix, cfg.PrestaShopVersion)
|
sessionService := pssession.NewService(
|
||||||
|
prestaDB,
|
||||||
|
cfg.PrestaShopTablePrefix,
|
||||||
|
cfg.PrestaShopVersion,
|
||||||
|
cfg.PrestaShopCookieName,
|
||||||
|
cfg.DomainCookie,
|
||||||
|
)
|
||||||
productRoute, err := routeService.LoadProductRoute(context.Background())
|
productRoute, err := routeService.LoadProductRoute(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load product route rule: %w", err)
|
return fmt.Errorf("load product route rule: %w", err)
|
||||||
@@ -86,6 +92,7 @@ func run() error {
|
|||||||
cartService,
|
cartService,
|
||||||
render.New(assetManifest),
|
render.New(assetManifest),
|
||||||
cfg,
|
cfg,
|
||||||
|
productRoute,
|
||||||
categoryRoute,
|
categoryRoute,
|
||||||
)
|
)
|
||||||
categoryHandler := handlers.NewCategoryHandler(
|
categoryHandler := handlers.NewCategoryHandler(
|
||||||
@@ -97,6 +104,16 @@ func run() error {
|
|||||||
productRoute,
|
productRoute,
|
||||||
categoryRoute,
|
categoryRoute,
|
||||||
)
|
)
|
||||||
|
cartHandler := handlers.NewCartHandler(cartService, cookieCodec, sessionService)
|
||||||
|
cartPageHandler := handlers.NewCartPageHandler(
|
||||||
|
productService,
|
||||||
|
customerService,
|
||||||
|
cartService,
|
||||||
|
render.New(assetManifest),
|
||||||
|
cfg,
|
||||||
|
productRoute,
|
||||||
|
categoryRoute,
|
||||||
|
)
|
||||||
|
|
||||||
proxyHandler, err := httpproxy.New(cfg.PrestaShopProxyTarget)
|
proxyHandler, err := httpproxy.New(cfg.PrestaShopProxyTarget)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -118,6 +135,11 @@ func run() error {
|
|||||||
e.Static("/dist", "web/dist")
|
e.Static("/dist", "web/dist")
|
||||||
e.GET("/healthz", handlers.Healthz())
|
e.GET("/healthz", handlers.Healthz())
|
||||||
e.GET("/readyz", handlers.Readyz(appStore, prestaDB, cfg.PrestaShopProxyTarget))
|
e.GET("/readyz", handlers.Readyz(appStore, prestaDB, cfg.PrestaShopProxyTarget))
|
||||||
|
e.Match([]string{http.MethodGet, http.MethodPost}, "/debug/cookie/decode", handlers.DecodeCookie(cookieCodec))
|
||||||
|
e.GET("/cart", cartPageHandler.Show)
|
||||||
|
e.GET("/:lang/cart", cartPageHandler.Show)
|
||||||
|
e.Match([]string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete}, "/cart", cartHandler.Handle)
|
||||||
|
e.Match([]string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete}, "/:lang/cart", cartHandler.Handle)
|
||||||
e.GET("/*", func(c echo.Context) error {
|
e.GET("/*", func(c echo.Context) error {
|
||||||
productMatch, productOK := productRoute.MatchInfo(c.Request().URL.Path)
|
productMatch, productOK := productRoute.MatchInfo(c.Request().URL.Path)
|
||||||
categoryMatch, categoryOK := categoryRoute.MatchInfo(c.Request().URL.Path)
|
categoryMatch, categoryOK := categoryRoute.MatchInfo(c.Request().URL.Path)
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
appmiddleware "git.ma-al.com/goc_marek/ps_shop/internal/http/middleware"
|
||||||
|
pscart "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cart"
|
||||||
|
pscookie "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cookie"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cartSessionService interface {
|
||||||
|
RefreshExpiry(ctx context.Context, session *pscookie.SessionContext) error
|
||||||
|
ResolveCookiePath(ctx context.Context, req *http.Request) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartHandler struct {
|
||||||
|
carts *pscart.Service
|
||||||
|
codec pscookie.Codec
|
||||||
|
sessions cartSessionService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCartHandler(carts *pscart.Service, codec pscookie.Codec, sessions cartSessionService) *CartHandler {
|
||||||
|
return &CartHandler{
|
||||||
|
carts: carts,
|
||||||
|
codec: codec,
|
||||||
|
sessions: sessions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CartHandler) Handle(c echo.Context) error {
|
||||||
|
if h == nil || h.carts == nil || h.codec == nil || h.sessions == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "cart handler is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
session := appmiddleware.GetSession(c)
|
||||||
|
action := cartActionFromRequest(c)
|
||||||
|
|
||||||
|
input, err := cartMutationInput(c, session)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var result *pscart.MutationResult
|
||||||
|
switch action {
|
||||||
|
case cartActionDelete:
|
||||||
|
result, err = h.carts.DeleteProduct(c.Request().Context(), input)
|
||||||
|
case cartActionUpdate:
|
||||||
|
result, err = h.carts.UpdateProduct(c.Request().Context(), input)
|
||||||
|
default:
|
||||||
|
result, err = h.carts.AddProduct(c.Request().Context(), input)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "cart mutation failed: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSessionCartID(session, result.CartID)
|
||||||
|
if err := h.writeSessionCookie(c, session); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "cart cookie update failed: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if wantsJSON(c.Request()) {
|
||||||
|
return c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect(http.StatusSeeOther, cartRedirectTarget(c.Request()))
|
||||||
|
}
|
||||||
|
|
||||||
|
type cartAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
cartActionAdd cartAction = "add"
|
||||||
|
cartActionUpdate cartAction = "update"
|
||||||
|
cartActionDelete cartAction = "delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cartActionFromRequest(c echo.Context) cartAction {
|
||||||
|
switch c.Request().Method {
|
||||||
|
case http.MethodDelete:
|
||||||
|
return cartActionDelete
|
||||||
|
case http.MethodPut, http.MethodPatch:
|
||||||
|
return cartActionUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
action := strings.ToLower(strings.TrimSpace(c.FormValue("action")))
|
||||||
|
switch action {
|
||||||
|
case string(cartActionDelete):
|
||||||
|
return cartActionDelete
|
||||||
|
case string(cartActionUpdate):
|
||||||
|
return cartActionUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case c.FormValue("delete") != "":
|
||||||
|
return cartActionDelete
|
||||||
|
case c.FormValue("update") != "":
|
||||||
|
return cartActionUpdate
|
||||||
|
default:
|
||||||
|
return cartActionAdd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cartMutationInput(c echo.Context, session *pscookie.SessionContext) (pscart.MutationInput, error) {
|
||||||
|
productID, err := formInt64(c, "id_product")
|
||||||
|
if err != nil || productID == 0 {
|
||||||
|
return pscart.MutationInput{}, fmt.Errorf("valid id_product is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
quantity := int64(1)
|
||||||
|
if raw := strings.TrimSpace(c.FormValue("qty")); raw != "" {
|
||||||
|
quantity, err = strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || quantity < 0 {
|
||||||
|
return pscart.MutationInput{}, fmt.Errorf("valid qty is required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
productAttributeID, err := firstFormInt64(c, "id_product_attribute", "ipa")
|
||||||
|
if err != nil {
|
||||||
|
return pscart.MutationInput{}, fmt.Errorf("valid id_product_attribute is required")
|
||||||
|
}
|
||||||
|
customizationID, err := firstFormInt64(c, "id_customization", "customization_id")
|
||||||
|
if err != nil {
|
||||||
|
return pscart.MutationInput{}, fmt.Errorf("valid id_customization is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return pscart.MutationInput{
|
||||||
|
CartID: int64Value(session.CartID),
|
||||||
|
ProductID: productID,
|
||||||
|
ProductAttributeID: productAttributeID,
|
||||||
|
CustomizationID: customizationID,
|
||||||
|
Quantity: quantity,
|
||||||
|
CustomerID: int64Value(session.CustomerID),
|
||||||
|
GuestID: int64Value(session.GuestID),
|
||||||
|
LanguageID: int64Value(session.LanguageID),
|
||||||
|
CurrencyID: int64Value(session.CurrencyID),
|
||||||
|
ShopID: int64Value(session.ShopID),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formInt64(c echo.Context, key string) (int64, error) {
|
||||||
|
raw := strings.TrimSpace(c.FormValue(key))
|
||||||
|
if raw == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return strconv.ParseInt(raw, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstFormInt64(c echo.Context, keys ...string) (int64, error) {
|
||||||
|
for _, key := range keys {
|
||||||
|
value, err := formInt64(c, key)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if value != 0 {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncSessionCartID(session *pscookie.SessionContext, cartID int64) {
|
||||||
|
if session == nil || cartID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.Values == nil {
|
||||||
|
session.Values = map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.CartID = int64Ptr(cartID)
|
||||||
|
session.Values["id_cart"] = strconv.FormatInt(cartID, 10)
|
||||||
|
session.OrderedKeys = appendOrderedKeyIfMissing(session.OrderedKeys, "id_cart")
|
||||||
|
session.OrderedKeys = moveOrderedKeyToEnd(session.OrderedKeys, "checksum")
|
||||||
|
session.Plaintext = ""
|
||||||
|
session.RawCookie = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendOrderedKeyIfMissing(keys []string, key string) []string {
|
||||||
|
for _, existing := range keys {
|
||||||
|
if existing == key {
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(keys, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveOrderedKeyToEnd(keys []string, key string) []string {
|
||||||
|
for i, existing := range keys {
|
||||||
|
if existing == key {
|
||||||
|
keys = append(keys[:i], keys[i+1:]...)
|
||||||
|
return append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Ptr(value int64) *int64 {
|
||||||
|
if value == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := value
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Value(value *int64) int64 {
|
||||||
|
if value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CartHandler) writeSessionCookie(c echo.Context, session *pscookie.SessionContext) error {
|
||||||
|
if err := h.sessions.RefreshExpiry(c.Request().Context(), session); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cookiePath, err := h.sessions.ResolveCookiePath(c.Request().Context(), c.Request())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := h.codec.Encode(session)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
session.RawCookie = encoded
|
||||||
|
writeSessionCookie(c.Request(), c.Response(), session, session.CookieName, encoded, cookiePath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSessionCookie(req *http.Request, res *echo.Response, session *pscookie.SessionContext, name, value, path string) {
|
||||||
|
maxAge := 1
|
||||||
|
if session != nil && session.ExpiresAt != nil {
|
||||||
|
maxAge = int(session.ExpiresAt.UTC().Unix())
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
header := fmt.Sprintf("%s=%s; path=%s; max-age=%d; HttpOnly; SameSite=Lax", name, value, path, maxAge)
|
||||||
|
if requestCookieSecure(req) {
|
||||||
|
header += "; Secure"
|
||||||
|
}
|
||||||
|
res.Header().Add(echo.HeaderSetCookie, header)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestCookieSecure(req *http.Request) bool {
|
||||||
|
if req == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if req.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
forwarded := req.Header.Get("X-Forwarded-Proto")
|
||||||
|
if strings.Contains(forwarded, ",") {
|
||||||
|
forwarded = strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||||
|
}
|
||||||
|
return strings.EqualFold(forwarded, "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantsJSON(req *http.Request) bool {
|
||||||
|
if req == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(req.Header.Get(echo.HeaderAccept)), "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cartRedirectTarget(req *http.Request) string {
|
||||||
|
if req == nil {
|
||||||
|
return "/cart?action=show"
|
||||||
|
}
|
||||||
|
referer := strings.TrimSpace(req.Referer())
|
||||||
|
if referer != "" {
|
||||||
|
return referer
|
||||||
|
}
|
||||||
|
path := requestLanguagePrefix(req) + "/cart"
|
||||||
|
if path == "/cart" {
|
||||||
|
return "/cart?action=show"
|
||||||
|
}
|
||||||
|
return path + "?action=show"
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
appmiddleware "git.ma-al.com/goc_marek/ps_shop/internal/http/middleware"
|
||||||
|
pscart "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cart"
|
||||||
|
pscatalog "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/catalog"
|
||||||
|
psconfig "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/config"
|
||||||
|
pscustomer "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/customer"
|
||||||
|
psroutes "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/routes"
|
||||||
|
"git.ma-al.com/goc_marek/ps_shop/internal/render"
|
||||||
|
"git.ma-al.com/goc_marek/ps_shop/internal/viewmodel"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CartPageHandler struct {
|
||||||
|
catalog *pscatalog.Service
|
||||||
|
customers *pscustomer.Service
|
||||||
|
carts *pscart.Service
|
||||||
|
renderer *render.Engine
|
||||||
|
config psconfig.Config
|
||||||
|
products *psroutes.ProductRoute
|
||||||
|
categories *psroutes.CategoryRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCartPageHandler(catalog *pscatalog.Service, customers *pscustomer.Service, carts *pscart.Service, renderer *render.Engine, cfg psconfig.Config, products *psroutes.ProductRoute, categories *psroutes.CategoryRoute) *CartPageHandler {
|
||||||
|
return &CartPageHandler{
|
||||||
|
catalog: catalog,
|
||||||
|
customers: customers,
|
||||||
|
carts: carts,
|
||||||
|
renderer: renderer,
|
||||||
|
config: cfg,
|
||||||
|
products: products,
|
||||||
|
categories: categories,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CartPageHandler) Show(c echo.Context) error {
|
||||||
|
session := appmiddleware.GetSession(c)
|
||||||
|
if h == nil || h.catalog == nil || h.carts == nil || h.renderer == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "cart page handler is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
languageID := int64Default(session.LanguageID, 1)
|
||||||
|
languageID = h.catalog.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
||||||
|
shopID := int64Default(session.ShopID, 1)
|
||||||
|
currencyID := int64Default(session.CurrencyID, 1)
|
||||||
|
|
||||||
|
var profile *pscustomer.Profile
|
||||||
|
var err error
|
||||||
|
if session.CustomerID != nil && h.customers != nil {
|
||||||
|
profile, err = h.customers.GetByID(c.Request().Context(), *session.CustomerID)
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "customer query failed: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cartPage := pscart.Page{}
|
||||||
|
cartSummary := &pscart.Summary{}
|
||||||
|
if session.CartID != nil {
|
||||||
|
cartPage, cartSummary, err = h.loadCart(c.Request().Context(), *session.CartID, languageID, shopID, currencyID)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "cart query failed: "+err.Error())
|
||||||
|
}
|
||||||
|
assignCartProductLinks(c.Request(), h.products, &cartPage)
|
||||||
|
assignCartProductImages(requestBaseURL(c.Request()), &cartPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
page := viewmodel.CartPageData{
|
||||||
|
Cart: cartPage,
|
||||||
|
Session: session,
|
||||||
|
Customer: profile,
|
||||||
|
CartSummary: cartSummary,
|
||||||
|
ShopBaseURL: h.config.PrestaShopBaseURL,
|
||||||
|
}
|
||||||
|
menu, err := loadMenu(c.Request(), h.catalog, h.categories, languageID, shopID)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "menu query failed: "+err.Error())
|
||||||
|
}
|
||||||
|
page.Menu = menu
|
||||||
|
locale, err := loadHeaderLocale(c.Request(), h.catalog, session, languageID)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "locale query failed: "+err.Error())
|
||||||
|
}
|
||||||
|
page.Locale = locale
|
||||||
|
|
||||||
|
return h.renderer.Cart(c.Response(), c.Request(), page)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CartPageHandler) loadCart(ctx context.Context, cartID, languageID, shopID, currencyID int64) (pscart.Page, *pscart.Summary, error) {
|
||||||
|
page, err := h.carts.PageByID(ctx, cartID, languageID, shopID, currencyID)
|
||||||
|
if err != nil {
|
||||||
|
return pscart.Page{}, nil, err
|
||||||
|
}
|
||||||
|
summary := &pscart.Summary{
|
||||||
|
ID: page.ID,
|
||||||
|
TotalItems: page.TotalItems,
|
||||||
|
}
|
||||||
|
return *page, summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignCartProductLinks(req *http.Request, route *psroutes.ProductRoute, page *pscart.Page) {
|
||||||
|
if page == nil || route == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
langPrefix := requestLanguagePrefix(req)
|
||||||
|
for i := range page.Items {
|
||||||
|
product := &page.Items[i]
|
||||||
|
product.URL = route.BuildPath(psroutes.ProductURLData{
|
||||||
|
ID: product.ProductID,
|
||||||
|
Slug: product.Slug,
|
||||||
|
CategoryPath: product.CategoryPath,
|
||||||
|
EAN13: product.EAN13,
|
||||||
|
LanguagePrefix: langPrefix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignCartProductImages(baseURL string, page *pscart.Page) {
|
||||||
|
if page == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range page.Items {
|
||||||
|
page.Items[i].ImageURL = prestashopImageURL(baseURL, page.Items[i].CoverImageID, "home_default")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
pscookie "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cookie"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCartActionFromRequestDefaultsToAdd(t *testing.T) {
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/cart", strings.NewReader(url.Values{
|
||||||
|
"id_product": []string{"42"},
|
||||||
|
}.Encode()))
|
||||||
|
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
|
if got := cartActionFromRequest(c); got != cartActionAdd {
|
||||||
|
t.Fatalf("cartActionFromRequest() = %q, want %q", got, cartActionAdd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartActionFromRequestHonorsDeleteFlag(t *testing.T) {
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/cart", strings.NewReader(url.Values{
|
||||||
|
"delete": []string{"1"},
|
||||||
|
"id_product": []string{"42"},
|
||||||
|
}.Encode()))
|
||||||
|
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
|
if got := cartActionFromRequest(c); got != cartActionDelete {
|
||||||
|
t.Fatalf("cartActionFromRequest() = %q, want %q", got, cartActionDelete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSessionCartIDPreservesChecksumOrder(t *testing.T) {
|
||||||
|
session := &pscookie.SessionContext{
|
||||||
|
Values: map[string]string{
|
||||||
|
"id_lang": "5",
|
||||||
|
"checksum": "123",
|
||||||
|
"id_guest": "11",
|
||||||
|
"id_cart": "",
|
||||||
|
"id_shop": "1",
|
||||||
|
"id_guest2": "ignored",
|
||||||
|
},
|
||||||
|
OrderedKeys: []string{"id_lang", "id_guest", "checksum"},
|
||||||
|
RawCookie: "raw",
|
||||||
|
Plaintext: "plaintext",
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSessionCartID(session, 99)
|
||||||
|
|
||||||
|
if session.CartID == nil || *session.CartID != 99 {
|
||||||
|
t.Fatalf("CartID = %v, want 99", session.CartID)
|
||||||
|
}
|
||||||
|
if got := session.Values["id_cart"]; got != "99" {
|
||||||
|
t.Fatalf("Values[id_cart] = %q, want %q", got, "99")
|
||||||
|
}
|
||||||
|
wantOrder := []string{"id_lang", "id_guest", "id_cart", "checksum"}
|
||||||
|
if len(session.OrderedKeys) != len(wantOrder) {
|
||||||
|
t.Fatalf("OrderedKeys length = %d, want %d", len(session.OrderedKeys), len(wantOrder))
|
||||||
|
}
|
||||||
|
for i, want := range wantOrder {
|
||||||
|
if got := session.OrderedKeys[i]; got != want {
|
||||||
|
t.Fatalf("OrderedKeys[%d] = %q, want %q", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session.RawCookie != "" {
|
||||||
|
t.Fatalf("RawCookie = %q, want empty", session.RawCookie)
|
||||||
|
}
|
||||||
|
if session.Plaintext != "" {
|
||||||
|
t.Fatalf("Plaintext = %q, want empty", session.Plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
@@ -52,12 +54,16 @@ func (h *CategoryHandler) Show(c echo.Context) error {
|
|||||||
languageID := int64Default(session.LanguageID, 1)
|
languageID := int64Default(session.LanguageID, 1)
|
||||||
languageID = h.catalog.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
languageID = h.catalog.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
||||||
shopID := int64Default(session.ShopID, 1)
|
shopID := int64Default(session.ShopID, 1)
|
||||||
|
currencyID := int64Default(session.CurrencyID, 1)
|
||||||
|
|
||||||
category, err := h.catalog.GetCategoryPage(c.Request().Context(), pscatalog.CategoryPageRequest{
|
category, err := h.catalog.GetCategoryPage(c.Request().Context(), pscatalog.CategoryPageRequest{
|
||||||
ID: categoryID(c),
|
ID: categoryID(c),
|
||||||
Slug: categorySlug(c),
|
Slug: categorySlug(c),
|
||||||
LanguageID: languageID,
|
LanguageID: languageID,
|
||||||
ShopID: shopID,
|
ShopID: shopID,
|
||||||
|
CurrencyID: currencyID,
|
||||||
|
Page: categoryPageParam(c.Request()),
|
||||||
|
PerPage: 30,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -89,7 +95,9 @@ func (h *CategoryHandler) Show(c echo.Context) error {
|
|||||||
CartSummary: cartSummary,
|
CartSummary: cartSummary,
|
||||||
ShopBaseURL: h.config.PrestaShopBaseURL,
|
ShopBaseURL: h.config.PrestaShopBaseURL,
|
||||||
}
|
}
|
||||||
|
page.Pagination = categoryPaginationView(c.Request(), category.Pagination)
|
||||||
assignCategoryProductLinks(c.Request(), h.products, &page)
|
assignCategoryProductLinks(c.Request(), h.products, &page)
|
||||||
|
assignCategoryProductImages(requestBaseURL(c.Request()), &page)
|
||||||
menu, err := loadMenu(c.Request(), h.catalog, h.categories, languageID, shopID)
|
menu, err := loadMenu(c.Request(), h.catalog, h.categories, languageID, shopID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "menu query failed: "+err.Error())
|
return echo.NewHTTPError(http.StatusInternalServerError, "menu query failed: "+err.Error())
|
||||||
@@ -137,10 +145,27 @@ func assignCategoryProductLinks(req *http.Request, route *psroutes.ProductRoute,
|
|||||||
if page == nil {
|
if page == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
assignProductCardsLinks(req, route, page.Category.Products, page.Category.Slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignCategoryProductImages(baseURL string, page *viewmodel.CategoryPageData) {
|
||||||
|
if page == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assignProductCardsImages(baseURL, page.Category.Products)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignProductCardsLinks(req *http.Request, route *psroutes.ProductRoute, products []pscatalog.CategoryProductCard, fallbackCategoryPath string) {
|
||||||
|
if route == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
langPrefix := requestLanguagePrefix(req)
|
langPrefix := requestLanguagePrefix(req)
|
||||||
categoryPath := page.Category.Slug
|
for i := range products {
|
||||||
for i := range page.Category.Products {
|
product := &products[i]
|
||||||
product := &page.Category.Products[i]
|
categoryPath := strings.TrimSpace(product.CategorySlug)
|
||||||
|
if categoryPath == "" {
|
||||||
|
categoryPath = fallbackCategoryPath
|
||||||
|
}
|
||||||
product.URL = route.BuildPath(psroutes.ProductURLData{
|
product.URL = route.BuildPath(psroutes.ProductURLData{
|
||||||
ID: product.ID,
|
ID: product.ID,
|
||||||
Slug: product.Slug,
|
Slug: product.Slug,
|
||||||
@@ -150,3 +175,65 @@ func assignCategoryProductLinks(req *http.Request, route *psroutes.ProductRoute,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assignProductCardsImages(baseURL string, products []pscatalog.CategoryProductCard) {
|
||||||
|
for i := range products {
|
||||||
|
products[i].ImageURL = prestashopImageURL(baseURL, products[i].CoverImageID, "home_default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryPageParam(req *http.Request) int {
|
||||||
|
if req == nil || req.URL == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(req.URL.Query().Get("page"))
|
||||||
|
if raw == "" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
page, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || page <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryPageURL(req *http.Request, page int) string {
|
||||||
|
if req == nil || req.URL == nil || page <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
query := url.Values{}
|
||||||
|
for key, values := range req.URL.Query() {
|
||||||
|
for _, value := range values {
|
||||||
|
query.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page <= 1 {
|
||||||
|
query.Del("page")
|
||||||
|
} else {
|
||||||
|
query.Set("page", strconv.Itoa(page))
|
||||||
|
}
|
||||||
|
path := req.URL.Path
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
if encoded := query.Encode(); encoded != "" {
|
||||||
|
return path + "?" + encoded
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryPaginationView(req *http.Request, meta pscatalog.CategoryPagination) viewmodel.CategoryPagination {
|
||||||
|
view := viewmodel.CategoryPagination{
|
||||||
|
Page: meta.Page,
|
||||||
|
PerPage: meta.PerPage,
|
||||||
|
TotalItems: meta.TotalItems,
|
||||||
|
TotalPages: meta.TotalPages,
|
||||||
|
}
|
||||||
|
if meta.Page > 1 {
|
||||||
|
view.PrevURL = categoryPageURL(req, meta.Page-1)
|
||||||
|
}
|
||||||
|
if meta.TotalPages > 0 && meta.Page < meta.TotalPages {
|
||||||
|
view.NextURL = categoryPageURL(req, meta.Page+1)
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
appmiddleware "git.ma-al.com/goc_marek/ps_shop/internal/http/middleware"
|
||||||
|
pscookie "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cookie"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cookieDecodeResponse struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
CookieName string `json:"cookie_name,omitempty"`
|
||||||
|
RawCookie string `json:"raw_cookie,omitempty"`
|
||||||
|
Plaintext string `json:"plaintext,omitempty"`
|
||||||
|
ParseStatus pscookie.ParseStatus `json:"parse_status"`
|
||||||
|
IsLoggedIn bool `json:"is_logged_in"`
|
||||||
|
CustomerID *int64 `json:"customer_id,omitempty"`
|
||||||
|
CartID *int64 `json:"cart_id,omitempty"`
|
||||||
|
LanguageID *int64 `json:"language_id,omitempty"`
|
||||||
|
CurrencyID *int64 `json:"currency_id,omitempty"`
|
||||||
|
ShopID *int64 `json:"shop_id,omitempty"`
|
||||||
|
GuestID *int64 `json:"guest_id,omitempty"`
|
||||||
|
OrderedKeys []string `json:"ordered_keys,omitempty"`
|
||||||
|
Values map[string]string `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeCookie(codec pscookie.Codec) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
raw := strings.TrimSpace(c.FormValue("value"))
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(c.FormValue("cookie"))
|
||||||
|
}
|
||||||
|
|
||||||
|
source := "request-session"
|
||||||
|
if raw != "" {
|
||||||
|
source = "request-parameter"
|
||||||
|
session, err := codec.Decode(raw)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "prestashop cookie decode failed: "+err.Error())
|
||||||
|
}
|
||||||
|
session.RawCookie = raw
|
||||||
|
return c.JSON(http.StatusOK, newCookieDecodeResponse(source, session))
|
||||||
|
}
|
||||||
|
|
||||||
|
session := appmiddleware.GetSession(c)
|
||||||
|
if session.RawCookie == "" && session.Plaintext == "" && len(session.Values) == 0 {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "missing prestashop cookie; pass ?value=<cookie> or send the cookie in the request")
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, newCookieDecodeResponse(source, session))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCookieDecodeResponse(source string, session *pscookie.SessionContext) cookieDecodeResponse {
|
||||||
|
if session == nil {
|
||||||
|
session = &pscookie.SessionContext{Values: map[string]string{}}
|
||||||
|
}
|
||||||
|
values := session.Values
|
||||||
|
if values == nil {
|
||||||
|
values = map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookieDecodeResponse{
|
||||||
|
Source: source,
|
||||||
|
CookieName: session.CookieName,
|
||||||
|
RawCookie: session.RawCookie,
|
||||||
|
Plaintext: session.Plaintext,
|
||||||
|
ParseStatus: session.ParseStatus,
|
||||||
|
IsLoggedIn: session.IsLoggedIn,
|
||||||
|
CustomerID: session.CustomerID,
|
||||||
|
CartID: session.CartID,
|
||||||
|
LanguageID: session.LanguageID,
|
||||||
|
CurrencyID: session.CurrencyID,
|
||||||
|
ShopID: session.ShopID,
|
||||||
|
GuestID: session.GuestID,
|
||||||
|
OrderedKeys: session.OrderedKeys,
|
||||||
|
Values: values,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
appmiddleware "git.ma-al.com/goc_marek/ps_shop/internal/http/middleware"
|
||||||
|
pscookie "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cookie"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testCookieKey = "def000008bf3d70e7012b7493c382d561e193218d0c74ab162fb0ea8029ce20e926531b4bcf0aaec9381152e6c161f198e06918b2d1aad67cc7cf40819a51ee328c63830"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecodeCookieFromQueryParameter(t *testing.T) {
|
||||||
|
codec, err := pscookie.NewCodec(pscookie.Config{
|
||||||
|
CookieName: "PrestaShop-test",
|
||||||
|
CookieKey: testCookieKey,
|
||||||
|
CookieIV: "vfRFMV42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCodec() error = %v", err)
|
||||||
|
}
|
||||||
|
testCookie, err := codec.Encode(&pscookie.SessionContext{
|
||||||
|
Values: map[string]string{
|
||||||
|
"date_add": "2026-05-13 18:51:06",
|
||||||
|
"id_lang": "1",
|
||||||
|
"id_language": "1",
|
||||||
|
"detect_language": "1",
|
||||||
|
"id_currency": "1",
|
||||||
|
"iso_code_country": "CZ",
|
||||||
|
},
|
||||||
|
OrderedKeys: []string{"date_add", "id_lang", "id_language", "detect_language", "iso_code_country", "id_currency", "checksum"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/debug/cookie/decode?value="+testCookie, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
|
if err := DecodeCookie(codec)(c); err != nil {
|
||||||
|
t.Fatalf("DecodeCookie() error = %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response cookieDecodeResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.Source != "request-parameter" {
|
||||||
|
t.Fatalf("source = %q, want request-parameter", response.Source)
|
||||||
|
}
|
||||||
|
if response.Values["id_lang"] != "1" {
|
||||||
|
t.Fatalf("id_lang = %q, want 1", response.Values["id_lang"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCookieFromSession(t *testing.T) {
|
||||||
|
codec, err := pscookie.NewCodec(pscookie.Config{
|
||||||
|
CookieName: "PrestaShop-test",
|
||||||
|
CookieKey: testCookieKey,
|
||||||
|
CookieIV: "vfRFMV42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCodec() error = %v", err)
|
||||||
|
}
|
||||||
|
testCookie, err := codec.Encode(&pscookie.SessionContext{
|
||||||
|
Values: map[string]string{
|
||||||
|
"date_add": "2026-05-13 18:51:06",
|
||||||
|
"id_lang": "1",
|
||||||
|
"id_language": "1",
|
||||||
|
"detect_language": "1",
|
||||||
|
"id_currency": "1",
|
||||||
|
"iso_code_country": "CZ",
|
||||||
|
},
|
||||||
|
OrderedKeys: []string{"date_add", "id_lang", "id_language", "detect_language", "iso_code_country", "id_currency", "checksum"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := codec.Decode(testCookie)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode() error = %v", err)
|
||||||
|
}
|
||||||
|
session.CookieName = "PrestaShop-test"
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/debug/cookie/decode", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
appmiddleware.SetSession(c, session)
|
||||||
|
|
||||||
|
if err := DecodeCookie(codec)(c); err != nil {
|
||||||
|
t.Fatalf("DecodeCookie() error = %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response cookieDecodeResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.Source != "request-session" {
|
||||||
|
t.Fatalf("source = %q, want request-session", response.Source)
|
||||||
|
}
|
||||||
|
if response.CookieName != "PrestaShop-test" {
|
||||||
|
t.Fatalf("cookie_name = %q, want PrestaShop-test", response.CookieName)
|
||||||
|
}
|
||||||
|
if response.Values["id_currency"] != "1" {
|
||||||
|
t.Fatalf("id_currency = %q, want 1", response.Values["id_currency"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func prestashopImageURL(baseURL string, imageID sql.NullInt64, imageType string) string {
|
||||||
|
if !imageID.Valid || imageID.Int64 == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id := strconv.FormatInt(imageID.Int64, 10)
|
||||||
|
var path strings.Builder
|
||||||
|
path.Grow(len(baseURL) + len(id)*2 + len(imageType) + 16)
|
||||||
|
path.WriteString(strings.TrimRight(baseURL, "/"))
|
||||||
|
path.WriteString("/img/p")
|
||||||
|
for _, ch := range id {
|
||||||
|
path.WriteByte('/')
|
||||||
|
path.WriteRune(ch)
|
||||||
|
}
|
||||||
|
path.WriteByte('/')
|
||||||
|
path.WriteString(id)
|
||||||
|
if strings.TrimSpace(imageType) != "" {
|
||||||
|
path.WriteByte('-')
|
||||||
|
path.WriteString(strings.TrimSpace(imageType))
|
||||||
|
}
|
||||||
|
path.WriteString(".webp")
|
||||||
|
return path.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestBaseURL(req *http.Request) string {
|
||||||
|
if req == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if req.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
} else if forwarded := req.Header.Get("X-Forwarded-Proto"); forwarded != "" {
|
||||||
|
if strings.Contains(forwarded, ",") {
|
||||||
|
forwarded = strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(forwarded) != "" {
|
||||||
|
scheme = strings.TrimSpace(forwarded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
host := req.Header.Get("X-Forwarded-Host")
|
||||||
|
if host == "" {
|
||||||
|
host = req.Host
|
||||||
|
}
|
||||||
|
if strings.Contains(host, ",") {
|
||||||
|
host = strings.TrimSpace(strings.Split(host, ",")[0])
|
||||||
|
}
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -27,16 +28,18 @@ type ProductHandler struct {
|
|||||||
carts *pscart.Service
|
carts *pscart.Service
|
||||||
renderer *render.Engine
|
renderer *render.Engine
|
||||||
config psconfig.Config
|
config psconfig.Config
|
||||||
|
productURL *psroutes.ProductRoute
|
||||||
categories *psroutes.CategoryRoute
|
categories *psroutes.CategoryRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProductHandler(products *pscatalog.Service, customers *pscustomer.Service, carts *pscart.Service, renderer *render.Engine, cfg psconfig.Config, categories *psroutes.CategoryRoute) *ProductHandler {
|
func NewProductHandler(products *pscatalog.Service, customers *pscustomer.Service, carts *pscart.Service, renderer *render.Engine, cfg psconfig.Config, productURL *psroutes.ProductRoute, categories *psroutes.CategoryRoute) *ProductHandler {
|
||||||
return &ProductHandler{
|
return &ProductHandler{
|
||||||
products: products,
|
products: products,
|
||||||
customers: customers,
|
customers: customers,
|
||||||
carts: carts,
|
carts: carts,
|
||||||
renderer: renderer,
|
renderer: renderer,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
productURL: productURL,
|
||||||
categories: categories,
|
categories: categories,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,12 +53,14 @@ func (h *ProductHandler) Show(c echo.Context) error {
|
|||||||
languageID := int64Default(session.LanguageID, 1)
|
languageID := int64Default(session.LanguageID, 1)
|
||||||
languageID = h.products.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
languageID = h.products.ResolveLanguageID(c.Request().Context(), c.Request(), languageID)
|
||||||
shopID := int64Default(session.ShopID, 1)
|
shopID := int64Default(session.ShopID, 1)
|
||||||
|
currencyID := int64Default(session.CurrencyID, 1)
|
||||||
|
|
||||||
product, err := h.products.GetProductPage(c.Request().Context(), pscatalog.ProductPageRequest{
|
product, err := h.products.GetProductPage(c.Request().Context(), pscatalog.ProductPageRequest{
|
||||||
ID: productID(c),
|
ID: productID(c),
|
||||||
Slug: productSlug(c),
|
Slug: productSlug(c),
|
||||||
LanguageID: languageID,
|
LanguageID: languageID,
|
||||||
ShopID: shopID,
|
ShopID: shopID,
|
||||||
|
CurrencyID: currencyID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -63,6 +68,14 @@ func (h *ProductHandler) Show(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
product.ImageURL = prestashopImageURL(requestBaseURL(c.Request()), product.CoverImageID, "large_default")
|
||||||
|
assignProductGalleryImages(requestBaseURL(c.Request()), product)
|
||||||
|
assignProductCombinationImages(requestBaseURL(c.Request()), product)
|
||||||
|
if product.ImageURL == "" && len(product.GalleryImages) > 0 {
|
||||||
|
product.ImageURL = product.GalleryImages[0].URL
|
||||||
|
}
|
||||||
|
assignProductCardsLinks(c.Request(), h.productURL, product.Accessories, "")
|
||||||
|
assignProductCardsImages(requestBaseURL(c.Request()), product.Accessories)
|
||||||
|
|
||||||
var profile *pscustomer.Profile
|
var profile *pscustomer.Profile
|
||||||
if session.CustomerID != nil && h.customers != nil {
|
if session.CustomerID != nil && h.customers != nil {
|
||||||
@@ -145,3 +158,31 @@ func productCategoryURL(req *http.Request, route *psroutes.CategoryRoute, produc
|
|||||||
LanguagePrefix: requestLanguagePrefix(req),
|
LanguagePrefix: requestLanguagePrefix(req),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assignProductGalleryImages(baseURL string, product *pscatalog.ProductPageData) {
|
||||||
|
if product == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range product.GalleryImages {
|
||||||
|
id := sqlNullInt64(product.GalleryImages[i].ID)
|
||||||
|
product.GalleryImages[i].URL = prestashopImageURL(baseURL, id, "large_default")
|
||||||
|
product.GalleryImages[i].ThumbURL = prestashopImageURL(baseURL, id, "home_default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignProductCombinationImages(baseURL string, product *pscatalog.ProductPageData) {
|
||||||
|
if product == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range product.Combinations {
|
||||||
|
product.Combinations[i].ImageURL = prestashopImageURL(baseURL, product.Combinations[i].ImageID, "large_default")
|
||||||
|
product.Combinations[i].ThumbURL = prestashopImageURL(baseURL, product.Combinations[i].ImageID, "home_default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sqlNullInt64(value int64) sql.NullInt64 {
|
||||||
|
if value == 0 {
|
||||||
|
return sql.NullInt64{}
|
||||||
|
}
|
||||||
|
return sql.NullInt64{Int64: value, Valid: true}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestApplyRequestMarketUsesSelectedCountryCurrency(t *testing.T) {
|
|||||||
t.Fatalf("iso_code_country = %q, want %q", got, "PL")
|
t.Fatalf("iso_code_country = %q, want %q", got, "PL")
|
||||||
}
|
}
|
||||||
if _, ok := session.Values["id_country"]; ok {
|
if _, ok := session.Values["id_country"]; ok {
|
||||||
t.Fatalf("id_country should not be persisted in anonymous market cookie")
|
t.Fatalf("id_country should not be added by Go market rewrite")
|
||||||
}
|
}
|
||||||
if got := session.Values["id_currency"]; got != "6" {
|
if got := session.Values["id_currency"]; got != "6" {
|
||||||
t.Fatalf("id_currency = %q, want %q", got, "6")
|
t.Fatalf("id_currency = %q, want %q", got, "6")
|
||||||
@@ -40,13 +40,13 @@ func TestApplyRequestMarketUsesSelectedCountryCurrency(t *testing.T) {
|
|||||||
if session.CurrencyID == nil || *session.CurrencyID != 6 {
|
if session.CurrencyID == nil || *session.CurrencyID != 6 {
|
||||||
t.Fatalf("CurrencyID = %v, want 6", session.CurrencyID)
|
t.Fatalf("CurrencyID = %v, want 6", session.CurrencyID)
|
||||||
}
|
}
|
||||||
if _, ok := session.Values["id_shop"]; ok {
|
if got := session.Values["id_shop"]; got != "1" {
|
||||||
t.Fatalf("id_shop should not be persisted in anonymous market cookie")
|
t.Fatalf("id_shop = %q, want %q", got, "1")
|
||||||
}
|
}
|
||||||
if _, ok := session.Values["id_cart"]; ok {
|
if got := session.Values["id_cart"]; got != "55" {
|
||||||
t.Fatalf("id_cart should not be persisted in anonymous market cookie")
|
t.Fatalf("id_cart = %q, want %q", got, "55")
|
||||||
}
|
}
|
||||||
wantOrder := []string{"date_add", "id_lang", "id_language", "iso_code_country", "id_currency", "id_guest", "id_connections", "checksum"}
|
wantOrder := []string{"date_add", "id_lang", "id_language", "id_currency", "id_guest", "id_connections", "id_shop", "id_cart", "iso_code_country", "checksum"}
|
||||||
for i, key := range wantOrder {
|
for i, key := range wantOrder {
|
||||||
if i >= len(session.OrderedKeys) || session.OrderedKeys[i] != key {
|
if i >= len(session.OrderedKeys) || session.OrderedKeys[i] != key {
|
||||||
t.Fatalf("OrderedKeys[%d] = %q, want %q; full=%v", i, session.OrderedKeys[i], key, session.OrderedKeys)
|
t.Fatalf("OrderedKeys[%d] = %q, want %q; full=%v", i, session.OrderedKeys[i], key, session.OrderedKeys)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/crc32"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,6 +25,10 @@ type SessionCookieNameResolver interface {
|
|||||||
ResolveCookieName(ctx context.Context, req *http.Request) (string, error)
|
ResolveCookieName(ctx context.Context, req *http.Request) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionCookiePathResolver interface {
|
||||||
|
ResolveCookiePath(ctx context.Context, req *http.Request) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
type LanguageResolver interface {
|
type LanguageResolver interface {
|
||||||
ResolveLanguageID(ctx context.Context, req *http.Request, fallback int64) int64
|
ResolveLanguageID(ctx context.Context, req *http.Request, fallback int64) int64
|
||||||
}
|
}
|
||||||
@@ -38,6 +41,7 @@ func Session(cfg psconfig.Config, codec pscookie.Codec, initializer AnonymousSes
|
|||||||
ownership := cfg.ParseRouteOwnership()
|
ownership := cfg.ParseRouteOwnership()
|
||||||
expiryRefresher, _ := initializer.(SessionExpiryRefresher)
|
expiryRefresher, _ := initializer.(SessionExpiryRefresher)
|
||||||
cookieNameResolver, _ := initializer.(SessionCookieNameResolver)
|
cookieNameResolver, _ := initializer.(SessionCookieNameResolver)
|
||||||
|
cookiePathResolver, _ := initializer.(SessionCookiePathResolver)
|
||||||
|
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
@@ -82,6 +86,16 @@ func Session(cfg psconfig.Config, codec pscookie.Codec, initializer AnonymousSes
|
|||||||
applyRequestMarket(session, requestMarketSelection(c.Request()))
|
applyRequestMarket(session, requestMarketSelection(c.Request()))
|
||||||
}
|
}
|
||||||
if ownedRoute && shouldSetSessionCookie(rawCookie, session) {
|
if ownedRoute && shouldSetSessionCookie(rawCookie, session) {
|
||||||
|
cookiePath := "/"
|
||||||
|
if cookiePathResolver != nil {
|
||||||
|
resolvedCookiePath, err := cookiePathResolver.ResolveCookiePath(c.Request().Context(), c.Request())
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("prestashop cookie path resolution failed: %v", err))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(resolvedCookiePath) != "" {
|
||||||
|
cookiePath = resolvedCookiePath
|
||||||
|
}
|
||||||
|
}
|
||||||
if expiryRefresher != nil {
|
if expiryRefresher != nil {
|
||||||
if err := expiryRefresher.RefreshExpiry(c.Request().Context(), session); err != nil {
|
if err := expiryRefresher.RefreshExpiry(c.Request().Context(), session); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("prestashop session expiry refresh failed: %v", err))
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("prestashop session expiry refresh failed: %v", err))
|
||||||
@@ -92,7 +106,7 @@ func Session(cfg psconfig.Config, codec pscookie.Codec, initializer AnonymousSes
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, "prestashop cookie encode failed")
|
return echo.NewHTTPError(http.StatusInternalServerError, "prestashop cookie encode failed")
|
||||||
}
|
}
|
||||||
session.RawCookie = encoded
|
session.RawCookie = encoded
|
||||||
setPrestaShopCookie(c.Request(), c.Response(), session, cookieName, encoded)
|
setPrestaShopCookie(c.Request(), c.Response(), session, cookieName, encoded, cookiePath)
|
||||||
if redirectURL, ok := clearMarketSelectionURL(c.Request()); ok {
|
if redirectURL, ok := clearMarketSelectionURL(c.Request()); ok {
|
||||||
return c.Redirect(http.StatusSeeOther, redirectURL)
|
return c.Redirect(http.StatusSeeOther, redirectURL)
|
||||||
}
|
}
|
||||||
@@ -153,20 +167,7 @@ func cookiePrefix(configuredName string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shouldBootstrapAnonymousSession(rawCookie string, session *pscookie.SessionContext) bool {
|
func shouldBootstrapAnonymousSession(rawCookie string, session *pscookie.SessionContext) bool {
|
||||||
if session == nil {
|
return session == nil || rawCookie == ""
|
||||||
return true
|
|
||||||
}
|
|
||||||
if rawCookie == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if session.IsLoggedIn {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return session.GuestID == nil ||
|
|
||||||
session.CurrencyID == nil ||
|
|
||||||
session.LanguageID == nil ||
|
|
||||||
session.Values["id_connections"] == "" ||
|
|
||||||
session.Values["iso_code_country"] == ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldSetSessionCookie(rawCookie string, session *pscookie.SessionContext) bool {
|
func shouldSetSessionCookie(rawCookie string, session *pscookie.SessionContext) bool {
|
||||||
@@ -195,15 +196,9 @@ func applyRequestLanguage(session *pscookie.SessionContext, languageID int64) {
|
|||||||
session.LanguageID = int64Ptr(languageID)
|
session.LanguageID = int64Ptr(languageID)
|
||||||
session.Values["id_lang"] = value
|
session.Values["id_lang"] = value
|
||||||
session.Values["id_language"] = value
|
session.Values["id_language"] = value
|
||||||
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "id_lang", 1)
|
session.OrderedKeys = appendOrderedKeyIfMissing(session.OrderedKeys, "id_lang")
|
||||||
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "id_language", 3)
|
session.OrderedKeys = appendOrderedKeyIfMissing(session.OrderedKeys, "id_language")
|
||||||
|
session.OrderedKeys = moveOrderedKeyToEnd(session.OrderedKeys, "checksum")
|
||||||
if !session.IsLoggedIn {
|
|
||||||
if checksum := anonymousSessionChecksum(session, languageID); checksum != "" {
|
|
||||||
session.Values["checksum"] = checksum
|
|
||||||
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "checksum", len(session.OrderedKeys))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
session.Plaintext = ""
|
session.Plaintext = ""
|
||||||
session.RawCookie = ""
|
session.RawCookie = ""
|
||||||
@@ -235,18 +230,9 @@ func applyRequestMarket(session *pscookie.SessionContext, selection marketSelect
|
|||||||
session.CurrencyID = int64Ptr(selection.CurrencyID)
|
session.CurrencyID = int64Ptr(selection.CurrencyID)
|
||||||
session.Values["iso_code_country"] = selection.CountryISO
|
session.Values["iso_code_country"] = selection.CountryISO
|
||||||
session.Values["id_currency"] = strconv.FormatInt(selection.CurrencyID, 10)
|
session.Values["id_currency"] = strconv.FormatInt(selection.CurrencyID, 10)
|
||||||
delete(session.Values, "id_country")
|
session.OrderedKeys = appendOrderedKeyIfMissing(session.OrderedKeys, "iso_code_country")
|
||||||
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "iso_code_country", 4)
|
session.OrderedKeys = appendOrderedKeyIfMissing(session.OrderedKeys, "id_currency")
|
||||||
session.OrderedKeys = removeOrderedKey(session.OrderedKeys, "id_country")
|
session.OrderedKeys = moveOrderedKeyToEnd(session.OrderedKeys, "checksum")
|
||||||
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "id_currency", 5)
|
|
||||||
|
|
||||||
if !session.IsLoggedIn {
|
|
||||||
trimAnonymousCookieValues(session)
|
|
||||||
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "id_guest", 6)
|
|
||||||
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "id_connections", 7)
|
|
||||||
session.OrderedKeys = removeOrderedKey(session.OrderedKeys, "checksum")
|
|
||||||
session.OrderedKeys = ensureOrderedKey(session.OrderedKeys, "checksum", len(session.OrderedKeys))
|
|
||||||
}
|
|
||||||
|
|
||||||
session.Plaintext = ""
|
session.Plaintext = ""
|
||||||
session.RawCookie = ""
|
session.RawCookie = ""
|
||||||
@@ -259,91 +245,28 @@ func sessionLanguageID(session *pscookie.SessionContext) int64 {
|
|||||||
return *session.LanguageID
|
return *session.LanguageID
|
||||||
}
|
}
|
||||||
|
|
||||||
func anonymousSessionChecksum(session *pscookie.SessionContext, languageID int64) string {
|
func appendOrderedKeyIfMissing(keys []string, key string) []string {
|
||||||
if session == nil || session.Values == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
guestID, _ := strconv.ParseInt(session.Values["id_guest"], 10, 64)
|
|
||||||
connectionID, _ := strconv.ParseInt(session.Values["id_connections"], 10, 64)
|
|
||||||
currencyID, _ := strconv.ParseInt(session.Values["id_currency"], 10, 64)
|
|
||||||
shopID, _ := strconv.ParseInt(session.Values["id_shop"], 10, 64)
|
|
||||||
if guestID == 0 || connectionID == 0 || currencyID == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := make([]byte, 0, 32)
|
|
||||||
for _, value := range []int64{guestID, connectionID, languageID, currencyID, shopID} {
|
|
||||||
buf = strconv.AppendInt(buf, value, 10)
|
|
||||||
buf = append(buf, '|')
|
|
||||||
}
|
|
||||||
return strconv.FormatUint(uint64(crc32.ChecksumIEEE(buf)), 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureOrderedKey(keys []string, key string, index int) []string {
|
|
||||||
for i, existing := range keys {
|
for i, existing := range keys {
|
||||||
if existing != key {
|
if existing != key {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if i == index || index >= len(keys) {
|
if i >= 0 {
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
keys = append(keys[:i], keys[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if index < 0 {
|
|
||||||
index = 0
|
|
||||||
}
|
|
||||||
if index >= len(keys) {
|
|
||||||
return append(keys, key)
|
return append(keys, key)
|
||||||
}
|
|
||||||
|
|
||||||
keys = append(keys, "")
|
|
||||||
copy(keys[index+1:], keys[index:])
|
|
||||||
keys[index] = key
|
|
||||||
return keys
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeOrderedKey(keys []string, key string) []string {
|
func moveOrderedKeyToEnd(keys []string, key string) []string {
|
||||||
for i, existing := range keys {
|
for i, existing := range keys {
|
||||||
if existing == key {
|
if existing == key {
|
||||||
return append(keys[:i], keys[i+1:]...)
|
keys = append(keys[:i], keys[i+1:]...)
|
||||||
|
return append(keys, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func trimAnonymousCookieValues(session *pscookie.SessionContext) {
|
|
||||||
if session == nil || session.Values == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
allowed := map[string]struct{}{
|
|
||||||
"date_add": {},
|
|
||||||
"id_lang": {},
|
|
||||||
"id_language": {},
|
|
||||||
"iso_code_country": {},
|
|
||||||
"id_currency": {},
|
|
||||||
"id_guest": {},
|
|
||||||
"id_connections": {},
|
|
||||||
"checksum": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for key := range session.Values {
|
|
||||||
if _, ok := allowed[key]; !ok {
|
|
||||||
delete(session.Values, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered := make([]string, 0, len(session.OrderedKeys))
|
|
||||||
for _, key := range session.OrderedKeys {
|
|
||||||
if _, ok := allowed[key]; ok {
|
|
||||||
filtered = append(filtered, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
session.OrderedKeys = filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
func int64Ptr(value int64) *int64 {
|
func int64Ptr(value int64) *int64 {
|
||||||
if value == 0 {
|
if value == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -413,13 +336,16 @@ func clearMarketSelectionURL(req *http.Request) (string, bool) {
|
|||||||
return cleanPath, true
|
return cleanPath, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPrestaShopCookie(req *http.Request, res *echo.Response, session *pscookie.SessionContext, name, value string) {
|
func setPrestaShopCookie(req *http.Request, res *echo.Response, session *pscookie.SessionContext, name, value, path string) {
|
||||||
maxAge := 1
|
maxAge := 1
|
||||||
if session != nil && session.ExpiresAt != nil {
|
if session != nil && session.ExpiresAt != nil {
|
||||||
maxAge = int(session.ExpiresAt.UTC().Unix())
|
maxAge = int(session.ExpiresAt.UTC().Unix())
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
|
||||||
header := fmt.Sprintf("%s=%s; path=/; max-age=%d; HttpOnly; SameSite=Lax", name, value, maxAge)
|
header := fmt.Sprintf("%s=%s; path=%s; max-age=%d; HttpOnly; SameSite=Lax", name, value, path, maxAge)
|
||||||
if requestCookieSecure(req) {
|
if requestCookieSecure(req) {
|
||||||
header += "; Secure"
|
header += "; Secure"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,54 @@ import (
|
|||||||
pscookie "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cookie"
|
pscookie "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cookie"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestShouldBootstrapAnonymousSession(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rawCookie string
|
||||||
|
session *pscookie.SessionContext
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing cookie bootstraps",
|
||||||
|
rawCookie: "",
|
||||||
|
session: &pscookie.SessionContext{},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil session bootstraps",
|
||||||
|
rawCookie: "cookie",
|
||||||
|
session: nil,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "incomplete anonymous cookie does not bootstrap",
|
||||||
|
rawCookie: "cookie",
|
||||||
|
session: &pscookie.SessionContext{
|
||||||
|
Values: map[string]string{
|
||||||
|
"id_lang": "5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "logged in cookie does not bootstrap",
|
||||||
|
rawCookie: "cookie",
|
||||||
|
session: &pscookie.SessionContext{
|
||||||
|
IsLoggedIn: true,
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := shouldBootstrapAnonymousSession(tc.rawCookie, tc.session); got != tc.want {
|
||||||
|
t.Fatalf("shouldBootstrapAnonymousSession() = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSetPrestaShopCookiePersistsExpiry(t *testing.T) {
|
func TestSetPrestaShopCookiePersistsExpiry(t *testing.T) {
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
req := httptest.NewRequest(http.MethodGet, "https://shop.example.com/product/test", nil)
|
req := httptest.NewRequest(http.MethodGet, "https://shop.example.com/product/test", nil)
|
||||||
@@ -21,7 +69,7 @@ func TestSetPrestaShopCookiePersistsExpiry(t *testing.T) {
|
|||||||
|
|
||||||
setPrestaShopCookie(req, res, &pscookie.SessionContext{
|
setPrestaShopCookie(req, res, &pscookie.SessionContext{
|
||||||
ExpiresAt: &expiresAt,
|
ExpiresAt: &expiresAt,
|
||||||
}, "PrestaShop-test", "value")
|
}, "PrestaShop-test", "value", "/")
|
||||||
|
|
||||||
setCookie := rec.Header().Get("Set-Cookie")
|
setCookie := rec.Header().Get("Set-Cookie")
|
||||||
if !strings.Contains(setCookie, "max-age=") {
|
if !strings.Contains(setCookie, "max-age=") {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ package cart
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -12,6 +17,63 @@ type Summary struct {
|
|||||||
TotalItems int64
|
TotalItems int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Page struct {
|
||||||
|
ID int64
|
||||||
|
Items []Item
|
||||||
|
TotalItems int64
|
||||||
|
Subtotal float64
|
||||||
|
SubtotalTaxIncl float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Item struct {
|
||||||
|
ProductID int64
|
||||||
|
ProductAttributeID int64
|
||||||
|
CustomizationID int64
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
CategoryPath string
|
||||||
|
EAN13 string
|
||||||
|
CoverImageID sql.NullInt64 `gorm:"column:cover_image_id"`
|
||||||
|
ImageURL string `gorm:"-"`
|
||||||
|
Quantity int64
|
||||||
|
UnitPrice float64
|
||||||
|
UnitPriceTaxIncl float64 `gorm:"column:unit_price_tax_incl"`
|
||||||
|
LineTotal float64
|
||||||
|
LineTotalTaxIncl float64 `gorm:"column:line_total_tax_incl"`
|
||||||
|
TaxRate float64 `gorm:"column:tax_rate"`
|
||||||
|
CurrencyID int64 `gorm:"column:currency_id"`
|
||||||
|
CurrencyCode string `gorm:"column:currency_code"`
|
||||||
|
CurrencySign string `gorm:"column:currency_sign"`
|
||||||
|
ConversionRate float64 `gorm:"column:conversion_rate"`
|
||||||
|
URL string `gorm:"-"`
|
||||||
|
Attributes []ItemAttribute `gorm:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItemAttribute struct {
|
||||||
|
Group string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutationInput struct {
|
||||||
|
CartID int64
|
||||||
|
ProductID int64
|
||||||
|
ProductAttributeID int64
|
||||||
|
CustomizationID int64
|
||||||
|
Quantity int64
|
||||||
|
CustomerID int64
|
||||||
|
GuestID int64
|
||||||
|
LanguageID int64
|
||||||
|
CurrencyID int64
|
||||||
|
ShopID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutationResult struct {
|
||||||
|
CartID int64
|
||||||
|
LineQuantity int64
|
||||||
|
TotalItems int64
|
||||||
|
CreatedCart bool
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
prefix string
|
prefix string
|
||||||
@@ -33,3 +95,676 @@ func (s *Service) SummaryByID(ctx context.Context, cartID int64) (*Summary, erro
|
|||||||
}
|
}
|
||||||
return &summary, nil
|
return &summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) PageByID(ctx context.Context, cartID, languageID, shopID, currencyID int64) (*Page, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil, errors.New("prestashop cart service is not initialized")
|
||||||
|
}
|
||||||
|
if cartID == 0 {
|
||||||
|
return &Page{}, nil
|
||||||
|
}
|
||||||
|
if languageID == 0 {
|
||||||
|
languageID = 1
|
||||||
|
}
|
||||||
|
if shopID == 0 {
|
||||||
|
shopID = 1
|
||||||
|
}
|
||||||
|
if currencyID == 0 {
|
||||||
|
currencyID = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT cp.id_product AS product_id,
|
||||||
|
COALESCE(cp.id_product_attribute, 0) AS product_attribute_id,
|
||||||
|
COALESCE(cp.id_customization, 0) AS customization_id,
|
||||||
|
pl.name AS name,
|
||||||
|
pl.link_rewrite AS slug,
|
||||||
|
cl.link_rewrite AS category_path,
|
||||||
|
p.ean13 AS ean13,
|
||||||
|
COALESCE(combination_image.id_image, i.id_image) AS cover_image_id,
|
||||||
|
cp.quantity AS quantity,
|
||||||
|
((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate) AS unit_price,
|
||||||
|
(((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS unit_price_tax_incl,
|
||||||
|
(cp.quantity * ((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate)) AS line_total,
|
||||||
|
(cp.quantity * (((product_shop.price + COALESCE(product_attribute_shop.price, 0)) * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100))) AS line_total_tax_incl,
|
||||||
|
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
|
||||||
|
curr.id_currency AS currency_id,
|
||||||
|
curr.iso_code AS currency_code,
|
||||||
|
curr.sign AS currency_sign,
|
||||||
|
curr.conversion_rate AS conversion_rate
|
||||||
|
FROM %scart_product cp
|
||||||
|
JOIN %sproduct p ON p.id_product = cp.id_product
|
||||||
|
JOIN %sproduct_shop product_shop
|
||||||
|
ON product_shop.id_product = cp.id_product
|
||||||
|
AND product_shop.id_shop = cp.id_shop
|
||||||
|
JOIN %sproduct_lang pl
|
||||||
|
ON pl.id_product = cp.id_product
|
||||||
|
AND pl.id_lang = ?
|
||||||
|
AND pl.id_shop = ?
|
||||||
|
LEFT JOIN %simage i ON i.id_product = cp.id_product AND i.cover = 1
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT pai.id_product_attribute,
|
||||||
|
MIN(pai.id_image) AS id_image
|
||||||
|
FROM %sproduct_attribute_image pai
|
||||||
|
GROUP BY pai.id_product_attribute
|
||||||
|
) combination_image
|
||||||
|
ON combination_image.id_product_attribute = cp.id_product_attribute
|
||||||
|
LEFT JOIN %sproduct_attribute_shop product_attribute_shop
|
||||||
|
ON product_attribute_shop.id_product_attribute = cp.id_product_attribute
|
||||||
|
AND product_attribute_shop.id_shop = cp.id_shop
|
||||||
|
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tr.id_tax_rules_group,
|
||||||
|
SUM(t.rate) AS tax_rate
|
||||||
|
FROM %stax_rule tr
|
||||||
|
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||||
|
GROUP BY tr.id_tax_rules_group
|
||||||
|
) tax_data ON tax_data.id_tax_rules_group = product_shop.id_tax_rules_group
|
||||||
|
LEFT JOIN %scategory_lang cl
|
||||||
|
ON cl.id_category = product_shop.id_category_default
|
||||||
|
AND cl.id_lang = pl.id_lang
|
||||||
|
AND cl.id_shop = pl.id_shop
|
||||||
|
WHERE cp.id_cart = ?
|
||||||
|
AND product_shop.active = 1
|
||||||
|
ORDER BY cp.date_add ASC, cp.id_product ASC, cp.id_product_attribute ASC
|
||||||
|
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
|
|
||||||
|
var items []Item
|
||||||
|
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), languageID, shopID, currencyID, cartID).Scan(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
page := &Page{ID: cartID, Items: items}
|
||||||
|
if err := s.loadItemAttributes(ctx, languageID, &page.Items); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
page.TotalItems += item.Quantity
|
||||||
|
page.Subtotal += item.LineTotal
|
||||||
|
page.SubtotalTaxIncl += item.LineTotalTaxIncl
|
||||||
|
}
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadItemAttributes(ctx context.Context, languageID int64, items *[]Item) error {
|
||||||
|
if items == nil || len(*items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeIDs := make([]int64, 0, len(*items))
|
||||||
|
indexByAttributeID := make(map[int64][]int)
|
||||||
|
for i, item := range *items {
|
||||||
|
if item.ProductAttributeID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := indexByAttributeID[item.ProductAttributeID]; !exists {
|
||||||
|
attributeIDs = append(attributeIDs, item.ProductAttributeID)
|
||||||
|
}
|
||||||
|
indexByAttributeID[item.ProductAttributeID] = append(indexByAttributeID[item.ProductAttributeID], i)
|
||||||
|
}
|
||||||
|
if len(attributeIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type attributeRow struct {
|
||||||
|
ProductAttributeID int64 `gorm:"column:id_product_attribute"`
|
||||||
|
GroupName string `gorm:"column:group_name"`
|
||||||
|
AttributeName string `gorm:"column:attribute_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT pac.id_product_attribute,
|
||||||
|
agl.public_name AS group_name,
|
||||||
|
al.name AS attribute_name
|
||||||
|
FROM %sproduct_attribute_combination pac
|
||||||
|
JOIN %sattribute a
|
||||||
|
ON a.id_attribute = pac.id_attribute
|
||||||
|
JOIN %sattribute_lang al
|
||||||
|
ON al.id_attribute = a.id_attribute
|
||||||
|
AND al.id_lang = ?
|
||||||
|
JOIN %sattribute_group ag
|
||||||
|
ON ag.id_attribute_group = a.id_attribute_group
|
||||||
|
JOIN %sattribute_group_lang agl
|
||||||
|
ON agl.id_attribute_group = ag.id_attribute_group
|
||||||
|
AND agl.id_lang = ?
|
||||||
|
WHERE pac.id_product_attribute IN ?
|
||||||
|
ORDER BY pac.id_product_attribute ASC, ag.position ASC, a.position ASC, al.name ASC
|
||||||
|
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
|
|
||||||
|
rows := make([]attributeRow, 0)
|
||||||
|
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), languageID, languageID, attributeIDs).Scan(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
indices := indexByAttributeID[row.ProductAttributeID]
|
||||||
|
for _, idx := range indices {
|
||||||
|
(*items)[idx].Attributes = append((*items)[idx].Attributes, ItemAttribute{
|
||||||
|
Group: strings.TrimSpace(row.GroupName),
|
||||||
|
Value: strings.TrimSpace(row.AttributeName),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AddProduct(ctx context.Context, input MutationInput) (*MutationResult, error) {
|
||||||
|
return s.mutateProduct(ctx, mutationAdd, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateProduct(ctx context.Context, input MutationInput) (*MutationResult, error) {
|
||||||
|
return s.mutateProduct(ctx, mutationSet, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteProduct(ctx context.Context, input MutationInput) (*MutationResult, error) {
|
||||||
|
return s.mutateProduct(ctx, mutationDelete, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mutationMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
mutationAdd mutationMode = "add"
|
||||||
|
mutationSet mutationMode = "set"
|
||||||
|
mutationDelete mutationMode = "delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
type productLine struct {
|
||||||
|
CartID int64 `gorm:"column:id_cart"`
|
||||||
|
ProductID int64 `gorm:"column:id_product"`
|
||||||
|
ProductAttributeID int64 `gorm:"column:id_product_attribute"`
|
||||||
|
CustomizationID int64 `gorm:"column:id_customization"`
|
||||||
|
AddressDeliveryID int64 `gorm:"column:id_address_delivery"`
|
||||||
|
Quantity int64 `gorm:"column:quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cartContext struct {
|
||||||
|
ID int64
|
||||||
|
ShopID int64 `gorm:"column:id_shop"`
|
||||||
|
ShopGroupID int64 `gorm:"column:id_shop_group"`
|
||||||
|
CustomerID int64 `gorm:"column:id_customer"`
|
||||||
|
GuestID int64 `gorm:"column:id_guest"`
|
||||||
|
LanguageID int64 `gorm:"column:id_lang"`
|
||||||
|
CurrencyID int64 `gorm:"column:id_currency"`
|
||||||
|
AddressDeliveryID int64 `gorm:"column:id_address_delivery"`
|
||||||
|
AddressInvoiceID int64 `gorm:"column:id_address_invoice"`
|
||||||
|
SecureKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) mutateProduct(ctx context.Context, mode mutationMode, input MutationInput) (*MutationResult, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil, errors.New("prestashop cart service is not initialized")
|
||||||
|
}
|
||||||
|
if input.ProductID == 0 {
|
||||||
|
return nil, errors.New("product id is required")
|
||||||
|
}
|
||||||
|
if mode != mutationDelete && input.Quantity <= 0 {
|
||||||
|
return nil, errors.New("quantity must be positive")
|
||||||
|
}
|
||||||
|
if input.ShopID == 0 {
|
||||||
|
input.ShopID = 1
|
||||||
|
}
|
||||||
|
if input.LanguageID == 0 {
|
||||||
|
input.LanguageID = 1
|
||||||
|
}
|
||||||
|
if input.CurrencyID == 0 {
|
||||||
|
input.CurrencyID = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var result MutationResult
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
cart, created, err := s.ensureCart(tx, input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.CartID = cart.ID
|
||||||
|
result.CreatedCart = created
|
||||||
|
|
||||||
|
attributeID, err := s.resolveProductAttributeID(tx, input.ProductID, input.ProductAttributeID, cart.ShopID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
input.ProductAttributeID = attributeID
|
||||||
|
|
||||||
|
if err := s.ensureProductExists(tx, input.ProductID, cart.ShopID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := s.loadProductLine(tx, cart.ID, input.ProductID, input.ProductAttributeID, input.CustomizationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case mutationDelete:
|
||||||
|
if err := s.deleteProductLine(tx, cart.ID, input.ProductID, input.ProductAttributeID, input.CustomizationID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.LineQuantity = 0
|
||||||
|
case mutationAdd:
|
||||||
|
addressID := cart.AddressDeliveryID
|
||||||
|
if line != nil && line.AddressDeliveryID != 0 {
|
||||||
|
addressID = line.AddressDeliveryID
|
||||||
|
}
|
||||||
|
newQty := input.Quantity
|
||||||
|
if line != nil {
|
||||||
|
newQty += line.Quantity
|
||||||
|
if err := s.updateProductLineQuantity(tx, line, newQty); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := s.insertProductLine(tx, cart, input.ProductID, input.ProductAttributeID, input.CustomizationID, addressID, newQty); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.LineQuantity = newQty
|
||||||
|
case mutationSet:
|
||||||
|
if input.Quantity == 0 {
|
||||||
|
if err := s.deleteProductLine(tx, cart.ID, input.ProductID, input.ProductAttributeID, input.CustomizationID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.LineQuantity = 0
|
||||||
|
} else if line != nil {
|
||||||
|
if err := s.updateProductLineQuantity(tx, line, input.Quantity); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.LineQuantity = input.Quantity
|
||||||
|
} else {
|
||||||
|
if err := s.insertProductLine(tx, cart, input.ProductID, input.ProductAttributeID, input.CustomizationID, cart.AddressDeliveryID, input.Quantity); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.LineQuantity = input.Quantity
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported cart mutation %q", mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.touchCart(tx, cart.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := s.summaryByIDWithDB(tx, cart.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.TotalItems = summary.TotalItems
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureCart(tx *gorm.DB, input MutationInput) (*cartContext, bool, error) {
|
||||||
|
if input.CartID != 0 {
|
||||||
|
cart, err := s.loadCart(tx, input.CartID)
|
||||||
|
if err == nil {
|
||||||
|
return cart, false, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := cartContext{
|
||||||
|
ShopID: input.ShopID,
|
||||||
|
CustomerID: input.CustomerID,
|
||||||
|
GuestID: input.GuestID,
|
||||||
|
LanguageID: input.LanguageID,
|
||||||
|
CurrencyID: input.CurrencyID,
|
||||||
|
}
|
||||||
|
|
||||||
|
shopGroupID, err := s.loadShopGroupID(tx, ctx.ShopID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
ctx.ShopGroupID = shopGroupID
|
||||||
|
|
||||||
|
if ctx.CustomerID != 0 {
|
||||||
|
ctx.AddressDeliveryID, ctx.AddressInvoiceID, err = s.loadCustomerAddressIDs(tx, ctx.CustomerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
ctx.SecureKey, err = s.loadCustomerSecureKey(tx, ctx.CustomerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cartID, err := s.insertCart(tx, &ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
ctx.ID = cartID
|
||||||
|
return &ctx, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadCart(tx *gorm.DB, cartID int64) (*cartContext, error) {
|
||||||
|
var cart cartContext
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT id_cart AS id,
|
||||||
|
COALESCE(id_shop, 0) AS id_shop,
|
||||||
|
COALESCE(id_shop_group, 0) AS id_shop_group,
|
||||||
|
COALESCE(id_customer, 0) AS id_customer,
|
||||||
|
COALESCE(id_guest, 0) AS id_guest,
|
||||||
|
COALESCE(id_lang, 0) AS id_lang,
|
||||||
|
COALESCE(id_currency, 0) AS id_currency,
|
||||||
|
COALESCE(id_address_delivery, 0) AS id_address_delivery,
|
||||||
|
COALESCE(id_address_invoice, 0) AS id_address_invoice,
|
||||||
|
COALESCE(secure_key, '') AS secure_key
|
||||||
|
FROM %scart
|
||||||
|
WHERE id_cart = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix)
|
||||||
|
err := tx.Raw(strings.TrimSpace(query), cartID).Scan(&cart).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cart.ID == 0 {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return &cart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadShopGroupID(tx *gorm.DB, shopID int64) (int64, error) {
|
||||||
|
var row struct {
|
||||||
|
ShopGroupID int64 `gorm:"column:id_shop_group"`
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("SELECT id_shop_group FROM %sshop WHERE id_shop = ? LIMIT 1", s.prefix)
|
||||||
|
if err := tx.Raw(query, shopID).Scan(&row).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if row.ShopGroupID == 0 {
|
||||||
|
return 0, fmt.Errorf("prestashop shop %d not found", shopID)
|
||||||
|
}
|
||||||
|
return row.ShopGroupID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadCustomerAddressIDs(tx *gorm.DB, customerID int64) (int64, int64, error) {
|
||||||
|
var row struct {
|
||||||
|
ID int64 `gorm:"column:id_address"`
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT id_address
|
||||||
|
FROM %saddress
|
||||||
|
WHERE id_customer = ?
|
||||||
|
AND deleted = 0
|
||||||
|
ORDER BY id_address ASC
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix)
|
||||||
|
if err := tx.Raw(strings.TrimSpace(query), customerID).Scan(&row).Error; err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return row.ID, row.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadCustomerSecureKey(tx *gorm.DB, customerID int64) (string, error) {
|
||||||
|
var row struct {
|
||||||
|
SecureKey string `gorm:"column:secure_key"`
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("SELECT COALESCE(secure_key, '') AS secure_key FROM %scustomer WHERE id_customer = ? LIMIT 1", s.prefix)
|
||||||
|
if err := tx.Raw(query, customerID).Scan(&row).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return row.SecureKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) insertCart(tx *gorm.DB, cart *cartContext) (int64, error) {
|
||||||
|
available, err := s.tableColumns(tx, s.prefix+"cart")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||||
|
columns := make([]string, 0, 16)
|
||||||
|
values := make([]any, 0, 16)
|
||||||
|
add := func(name string, value any) {
|
||||||
|
if available[name] {
|
||||||
|
columns = append(columns, name)
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add("id_shop_group", cart.ShopGroupID)
|
||||||
|
add("id_shop", cart.ShopID)
|
||||||
|
add("id_address_delivery", cart.AddressDeliveryID)
|
||||||
|
add("id_address_invoice", cart.AddressInvoiceID)
|
||||||
|
add("id_carrier", 0)
|
||||||
|
add("id_currency", cart.CurrencyID)
|
||||||
|
add("id_customer", cart.CustomerID)
|
||||||
|
add("id_guest", cart.GuestID)
|
||||||
|
add("id_lang", cart.LanguageID)
|
||||||
|
add("recyclable", 0)
|
||||||
|
add("gift", 0)
|
||||||
|
add("gift_message", "")
|
||||||
|
add("mobile_theme", 0)
|
||||||
|
add("delivery_option", "")
|
||||||
|
add("secure_key", cart.SecureKey)
|
||||||
|
add("allow_seperated_package", 0)
|
||||||
|
add("date_add", now)
|
||||||
|
add("date_upd", now)
|
||||||
|
|
||||||
|
if err := tx.Exec(insertQuery(s.prefix+"cart", columns), values...).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var row struct {
|
||||||
|
ID int64 `gorm:"column:id"`
|
||||||
|
}
|
||||||
|
if err := tx.Raw("SELECT LAST_INSERT_ID() AS id").Scan(&row).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if row.ID == 0 {
|
||||||
|
return 0, errors.New("cart insert did not return an id")
|
||||||
|
}
|
||||||
|
return row.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveProductAttributeID(tx *gorm.DB, productID, productAttributeID, shopID int64) (int64, error) {
|
||||||
|
if productAttributeID != 0 {
|
||||||
|
var row struct {
|
||||||
|
ID int64 `gorm:"column:id_product_attribute"`
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT pa.id_product_attribute
|
||||||
|
FROM %sproduct_attribute pa
|
||||||
|
WHERE pa.id_product_attribute = ?
|
||||||
|
AND pa.id_product = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix)
|
||||||
|
if err := tx.Raw(strings.TrimSpace(query), productAttributeID, productID).Scan(&row).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if row.ID == 0 {
|
||||||
|
return 0, fmt.Errorf("product attribute %d does not belong to product %d", productAttributeID, productID)
|
||||||
|
}
|
||||||
|
return row.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var row struct {
|
||||||
|
ID int64 `gorm:"column:id_product_attribute"`
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT pa.id_product_attribute
|
||||||
|
FROM %sproduct_attribute pa
|
||||||
|
LEFT JOIN %sproduct_attribute_shop pas
|
||||||
|
ON pas.id_product_attribute = pa.id_product_attribute
|
||||||
|
AND pas.id_shop = ?
|
||||||
|
WHERE pa.id_product = ?
|
||||||
|
ORDER BY CASE WHEN COALESCE(pas.default_on, 0) = 1 THEN 0 ELSE 1 END,
|
||||||
|
pa.id_product_attribute ASC
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix, s.prefix)
|
||||||
|
if err := tx.Raw(strings.TrimSpace(query), shopID, productID).Scan(&row).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return row.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureProductExists(tx *gorm.DB, productID, shopID int64) error {
|
||||||
|
var row struct {
|
||||||
|
ID int64 `gorm:"column:id_product"`
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT p.id_product
|
||||||
|
FROM %sproduct p
|
||||||
|
JOIN %sproduct_shop ps
|
||||||
|
ON ps.id_product = p.id_product
|
||||||
|
AND ps.id_shop = ?
|
||||||
|
WHERE p.id_product = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix, s.prefix)
|
||||||
|
if err := tx.Raw(strings.TrimSpace(query), shopID, productID).Scan(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if row.ID == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadProductLine(tx *gorm.DB, cartID, productID, productAttributeID, customizationID int64) (*productLine, error) {
|
||||||
|
var line productLine
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT id_cart,
|
||||||
|
id_product,
|
||||||
|
COALESCE(id_product_attribute, 0) AS id_product_attribute,
|
||||||
|
COALESCE(id_customization, 0) AS id_customization,
|
||||||
|
COALESCE(id_address_delivery, 0) AS id_address_delivery,
|
||||||
|
quantity
|
||||||
|
FROM %scart_product
|
||||||
|
WHERE id_cart = ?
|
||||||
|
AND id_product = ?
|
||||||
|
AND COALESCE(id_product_attribute, 0) = ?
|
||||||
|
AND COALESCE(id_customization, 0) = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix)
|
||||||
|
if err := tx.Raw(strings.TrimSpace(query), cartID, productID, productAttributeID, customizationID).Scan(&line).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if line.CartID == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) insertProductLine(tx *gorm.DB, cart *cartContext, productID, productAttributeID, customizationID, addressDeliveryID, quantity int64) error {
|
||||||
|
available, err := s.tableColumns(tx, s.prefix+"cart_product")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||||
|
columns := make([]string, 0, 8)
|
||||||
|
values := make([]any, 0, 8)
|
||||||
|
add := func(name string, value any) {
|
||||||
|
if available[name] {
|
||||||
|
columns = append(columns, name)
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add("id_product", productID)
|
||||||
|
add("id_product_attribute", productAttributeID)
|
||||||
|
add("id_cart", cart.ID)
|
||||||
|
add("id_address_delivery", addressDeliveryID)
|
||||||
|
add("id_shop", cart.ShopID)
|
||||||
|
add("quantity", quantity)
|
||||||
|
add("date_add", now)
|
||||||
|
add("id_customization", customizationID)
|
||||||
|
|
||||||
|
return tx.Exec(insertQuery(s.prefix+"cart_product", columns), values...).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) updateProductLineQuantity(tx *gorm.DB, line *productLine, quantity int64) error {
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
UPDATE %scart_product
|
||||||
|
SET quantity = ?
|
||||||
|
WHERE id_cart = ?
|
||||||
|
AND id_product = ?
|
||||||
|
AND COALESCE(id_product_attribute, 0) = ?
|
||||||
|
AND COALESCE(id_customization, 0) = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, s.prefix)
|
||||||
|
return tx.Exec(strings.TrimSpace(query), quantity, line.CartID, line.ProductID, line.ProductAttributeID, line.CustomizationID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) deleteProductLine(tx *gorm.DB, cartID, productID, productAttributeID, customizationID int64) error {
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
DELETE FROM %scart_product
|
||||||
|
WHERE id_cart = ?
|
||||||
|
AND id_product = ?
|
||||||
|
AND COALESCE(id_product_attribute, 0) = ?
|
||||||
|
AND COALESCE(id_customization, 0) = ?
|
||||||
|
`, s.prefix)
|
||||||
|
return tx.Exec(strings.TrimSpace(query), cartID, productID, productAttributeID, customizationID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) touchCart(tx *gorm.DB, cartID int64) error {
|
||||||
|
query := fmt.Sprintf("UPDATE %scart SET date_upd = ? WHERE id_cart = ?", s.prefix)
|
||||||
|
return tx.Exec(query, time.Now().UTC().Format("2006-01-02 15:04:05"), cartID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) summaryByIDWithDB(tx *gorm.DB, cartID int64) (*Summary, error) {
|
||||||
|
var summary Summary
|
||||||
|
query := fmt.Sprintf("SELECT id_cart AS id, COALESCE(SUM(quantity), 0) AS total_items FROM %scart_product WHERE id_cart = ? GROUP BY id_cart", s.prefix)
|
||||||
|
result := tx.Raw(query, cartID).Scan(&summary)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return &Summary{ID: cartID}, nil
|
||||||
|
}
|
||||||
|
return &summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) tableColumns(tx *gorm.DB, tableName string) (map[string]bool, error) {
|
||||||
|
type row struct {
|
||||||
|
ColumnName string `gorm:"column:COLUMN_NAME"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
query := `
|
||||||
|
SELECT COLUMN_NAME
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = ?
|
||||||
|
`
|
||||||
|
if err := tx.Raw(query, tableName).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
columns := make(map[string]bool, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
columns[row.ColumnName] = true
|
||||||
|
}
|
||||||
|
return columns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertQuery(tableName string, columns []string) string {
|
||||||
|
if len(columns) == 0 {
|
||||||
|
return fmt.Sprintf("INSERT INTO %s () VALUES ()", tableName)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"INSERT INTO %s (%s) VALUES (%s)",
|
||||||
|
tableName,
|
||||||
|
strings.Join(columns, ", "),
|
||||||
|
placeholders(len(columns)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholders(n int) string {
|
||||||
|
parts := make([]string, n)
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = "?"
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatInt64(value int64) string {
|
||||||
|
if value == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(value, 10)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type ProductPageRequest struct {
|
|||||||
Slug string
|
Slug string
|
||||||
LanguageID int64
|
LanguageID int64
|
||||||
ShopID int64
|
ShopID int64
|
||||||
|
CurrencyID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryPageRequest struct {
|
type CategoryPageRequest struct {
|
||||||
@@ -22,6 +23,9 @@ type CategoryPageRequest struct {
|
|||||||
Slug string
|
Slug string
|
||||||
LanguageID int64
|
LanguageID int64
|
||||||
ShopID int64
|
ShopID int64
|
||||||
|
CurrencyID int64
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProductPageData struct {
|
type ProductPageData struct {
|
||||||
@@ -31,10 +35,55 @@ type ProductPageData struct {
|
|||||||
ShortDescription string
|
ShortDescription string
|
||||||
Description string
|
Description string
|
||||||
Price float64
|
Price float64
|
||||||
|
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
|
||||||
|
TaxRate float64 `gorm:"column:tax_rate"`
|
||||||
|
CurrencyID int64 `gorm:"column:currency_id"`
|
||||||
|
CurrencyCode string `gorm:"column:currency_code"`
|
||||||
|
CurrencySign string `gorm:"column:currency_sign"`
|
||||||
|
ConversionRate float64 `gorm:"column:conversion_rate"`
|
||||||
CoverImageID sql.NullInt64
|
CoverImageID sql.NullInt64
|
||||||
|
ImageURL string `gorm:"-"`
|
||||||
|
GalleryImages []ProductImage `gorm:"-"`
|
||||||
CategoryID int64
|
CategoryID int64
|
||||||
CategorySlug string
|
CategorySlug string
|
||||||
CategoryName string
|
CategoryName string
|
||||||
|
Features []ProductFeature `gorm:"-"`
|
||||||
|
Accessories []CategoryProductCard `gorm:"-"`
|
||||||
|
Combinations []ProductCombination `gorm:"-"`
|
||||||
|
DefaultAttribute int64 `gorm:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductImage struct {
|
||||||
|
ID int64 `gorm:"column:id_image"`
|
||||||
|
Cover bool `gorm:"column:cover"`
|
||||||
|
Position int `gorm:"column:position"`
|
||||||
|
URL string `gorm:"-"`
|
||||||
|
ThumbURL string `gorm:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductFeature struct {
|
||||||
|
ID int64 `gorm:"column:id_feature"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Value string `gorm:"column:value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductCombination struct {
|
||||||
|
ID int64 `gorm:"column:id_product_attribute"`
|
||||||
|
Price float64 `gorm:"column:price"`
|
||||||
|
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
|
||||||
|
DefaultOn bool `gorm:"column:default_on"`
|
||||||
|
ImageID sql.NullInt64 `gorm:"-"`
|
||||||
|
ImageURL string `gorm:"-"`
|
||||||
|
ThumbURL string `gorm:"-"`
|
||||||
|
Attributes []ProductCombinationAttribute `gorm:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductCombinationAttribute struct {
|
||||||
|
Group string
|
||||||
|
PublicName string
|
||||||
|
Value string
|
||||||
|
GroupType string
|
||||||
|
Color string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryPageData struct {
|
type CategoryPageData struct {
|
||||||
@@ -43,15 +92,32 @@ type CategoryPageData struct {
|
|||||||
Slug string
|
Slug string
|
||||||
Description string
|
Description string
|
||||||
Products []CategoryProductCard `gorm:"-"`
|
Products []CategoryProductCard `gorm:"-"`
|
||||||
|
Pagination CategoryPagination `gorm:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryPagination struct {
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
TotalItems int64
|
||||||
|
TotalPages int
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryProductCard struct {
|
type CategoryProductCard struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
Slug string
|
Slug string
|
||||||
|
CategorySlug string `gorm:"column:category_slug"`
|
||||||
URL string `gorm:"-"`
|
URL string `gorm:"-"`
|
||||||
|
ImageURL string `gorm:"-"`
|
||||||
Price float64
|
Price float64
|
||||||
Description string
|
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
|
||||||
|
TaxRate float64 `gorm:"column:tax_rate"`
|
||||||
|
CurrencyID int64 `gorm:"column:currency_id"`
|
||||||
|
CurrencyCode string `gorm:"column:currency_code"`
|
||||||
|
CurrencySign string `gorm:"column:currency_sign"`
|
||||||
|
ConversionRate float64 `gorm:"column:conversion_rate"`
|
||||||
|
CoverImageID sql.NullInt64 `gorm:"column:cover_image_id"`
|
||||||
|
ShortDescription string `gorm:"column:short_description"`
|
||||||
EAN13 string
|
EAN13 string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,54 +158,85 @@ func NewService(db *gorm.DB, prefix string) *Service {
|
|||||||
|
|
||||||
func (s *Service) GetProductPage(ctx context.Context, req ProductPageRequest) (*ProductPageData, error) {
|
func (s *Service) GetProductPage(ctx context.Context, req ProductPageRequest) (*ProductPageData, error) {
|
||||||
var product ProductPageData
|
var product ProductPageData
|
||||||
|
if req.CurrencyID == 0 {
|
||||||
|
req.CurrencyID = 1
|
||||||
|
}
|
||||||
queryByID := fmt.Sprintf(`
|
queryByID := fmt.Sprintf(`
|
||||||
SELECT p.id_product AS id,
|
SELECT p.id_product AS id,
|
||||||
pl.name AS name,
|
pl.name AS name,
|
||||||
pl.link_rewrite AS slug,
|
pl.link_rewrite AS slug,
|
||||||
pl.description_short AS short_description,
|
pl.description_short AS short_description,
|
||||||
pl.description AS description,
|
pl.description AS description,
|
||||||
ps.price AS price,
|
(ps.price * curr.conversion_rate) AS price,
|
||||||
|
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
|
||||||
|
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
|
||||||
|
curr.id_currency AS currency_id,
|
||||||
|
curr.iso_code AS currency_code,
|
||||||
|
curr.sign AS currency_sign,
|
||||||
|
curr.conversion_rate AS conversion_rate,
|
||||||
i.id_image AS cover_image_id,
|
i.id_image AS cover_image_id,
|
||||||
p.id_category_default AS category_id,
|
p.id_category_default AS category_id,
|
||||||
cl.link_rewrite AS category_slug,
|
cl.link_rewrite AS category_slug,
|
||||||
cl.name AS category_name
|
cl.name AS category_name
|
||||||
FROM %sproduct p
|
FROM %sproduct p
|
||||||
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
|
||||||
|
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tr.id_tax_rules_group,
|
||||||
|
SUM(t.rate) AS tax_rate
|
||||||
|
FROM %stax_rule tr
|
||||||
|
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||||
|
GROUP BY tr.id_tax_rules_group
|
||||||
|
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
|
||||||
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
||||||
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||||
WHERE p.id_product = ?
|
WHERE p.id_product = ?
|
||||||
|
AND ps.active = 1
|
||||||
AND pl.id_lang = ?
|
AND pl.id_lang = ?
|
||||||
AND ps.id_shop = ?
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
queryBySlug := fmt.Sprintf(`
|
queryBySlug := fmt.Sprintf(`
|
||||||
SELECT p.id_product AS id,
|
SELECT p.id_product AS id,
|
||||||
pl.name AS name,
|
pl.name AS name,
|
||||||
pl.link_rewrite AS slug,
|
pl.link_rewrite AS slug,
|
||||||
pl.description_short AS short_description,
|
pl.description_short AS short_description,
|
||||||
pl.description AS description,
|
pl.description AS description,
|
||||||
ps.price AS price,
|
(ps.price * curr.conversion_rate) AS price,
|
||||||
|
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
|
||||||
|
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
|
||||||
|
curr.id_currency AS currency_id,
|
||||||
|
curr.iso_code AS currency_code,
|
||||||
|
curr.sign AS currency_sign,
|
||||||
|
curr.conversion_rate AS conversion_rate,
|
||||||
i.id_image AS cover_image_id,
|
i.id_image AS cover_image_id,
|
||||||
p.id_category_default AS category_id,
|
p.id_category_default AS category_id,
|
||||||
cl.link_rewrite AS category_slug,
|
cl.link_rewrite AS category_slug,
|
||||||
cl.name AS category_name
|
cl.name AS category_name
|
||||||
FROM %sproduct p
|
FROM %sproduct p
|
||||||
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
|
||||||
|
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tr.id_tax_rules_group,
|
||||||
|
SUM(t.rate) AS tax_rate
|
||||||
|
FROM %stax_rule tr
|
||||||
|
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||||
|
GROUP BY tr.id_tax_rules_group
|
||||||
|
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
|
||||||
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
||||||
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||||
WHERE pl.link_rewrite = ?
|
WHERE pl.link_rewrite = ?
|
||||||
|
AND ps.active = 1
|
||||||
AND pl.id_lang = ?
|
AND pl.id_lang = ?
|
||||||
AND ps.id_shop = ?
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
|
|
||||||
var result *gorm.DB
|
var result *gorm.DB
|
||||||
if req.ID != 0 {
|
if req.ID != 0 {
|
||||||
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryByID), req.ID, req.LanguageID, req.ShopID).Scan(&product)
|
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryByID), req.ShopID, req.CurrencyID, req.ID, req.LanguageID).Scan(&product)
|
||||||
} else {
|
} else {
|
||||||
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryBySlug), req.Slug, req.LanguageID, req.ShopID).Scan(&product)
|
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryBySlug), req.ShopID, req.CurrencyID, req.Slug, req.LanguageID).Scan(&product)
|
||||||
}
|
}
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
@@ -147,12 +244,316 @@ LIMIT 1
|
|||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
return nil, gorm.ErrRecordNotFound
|
return nil, gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
|
features, err := s.loadProductFeatures(ctx, product.ID, req.LanguageID, req.ShopID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
product.Features = features
|
||||||
|
images, err := s.loadProductImages(ctx, product.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
product.GalleryImages = images
|
||||||
|
accessories, err := s.loadProductAccessories(ctx, product.ID, req.LanguageID, req.ShopID, req.CurrencyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
product.Accessories = accessories
|
||||||
|
combinations, err := s.loadProductCombinations(ctx, product.ID, req.LanguageID, req.ShopID, req.CurrencyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
product.Combinations = combinations
|
||||||
|
for _, combination := range combinations {
|
||||||
|
if combination.DefaultOn {
|
||||||
|
product.DefaultAttribute = combination.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if product.DefaultAttribute == 0 && len(combinations) > 0 {
|
||||||
|
product.DefaultAttribute = combinations[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
return &product, nil
|
return &product, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadProductImages(ctx context.Context, productID int64) ([]ProductImage, error) {
|
||||||
|
if productID == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT i.id_image,
|
||||||
|
CASE WHEN COALESCE(i.cover, 0) = 1 THEN 1 ELSE 0 END AS cover,
|
||||||
|
COALESCE(i.position, 0) AS position
|
||||||
|
FROM %simage i
|
||||||
|
WHERE i.id_product = ?
|
||||||
|
ORDER BY CASE WHEN COALESCE(i.cover, 0) = 1 THEN 0 ELSE 1 END,
|
||||||
|
COALESCE(i.position, 0) ASC,
|
||||||
|
i.id_image ASC
|
||||||
|
`, s.prefix)
|
||||||
|
|
||||||
|
images := make([]ProductImage, 0)
|
||||||
|
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), productID).Scan(&images).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return images, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadProductAccessories(ctx context.Context, productID, languageID, shopID, currencyID int64) ([]CategoryProductCard, error) {
|
||||||
|
if productID == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT p.id_product AS id,
|
||||||
|
pl.name AS name,
|
||||||
|
pl.link_rewrite AS slug,
|
||||||
|
p.ean13 AS ean13,
|
||||||
|
cl.link_rewrite AS category_slug,
|
||||||
|
(ps.price * curr.conversion_rate) AS price,
|
||||||
|
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
|
||||||
|
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
|
||||||
|
curr.id_currency AS currency_id,
|
||||||
|
curr.iso_code AS currency_code,
|
||||||
|
curr.sign AS currency_sign,
|
||||||
|
curr.conversion_rate AS conversion_rate,
|
||||||
|
i.id_image AS cover_image_id,
|
||||||
|
pl.description_short AS short_description
|
||||||
|
FROM %saccessory a
|
||||||
|
JOIN %sproduct p ON p.id_product = a.id_product_2
|
||||||
|
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||||
|
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
|
||||||
|
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
|
||||||
|
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||||
|
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tr.id_tax_rules_group,
|
||||||
|
SUM(t.rate) AS tax_rate
|
||||||
|
FROM %stax_rule tr
|
||||||
|
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||||
|
GROUP BY tr.id_tax_rules_group
|
||||||
|
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
|
||||||
|
WHERE a.id_product_1 = ?
|
||||||
|
AND ps.active = 1
|
||||||
|
AND pl.id_lang = ?
|
||||||
|
ORDER BY p.id_product ASC
|
||||||
|
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
|
|
||||||
|
products := make([]CategoryProductCard, 0)
|
||||||
|
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), shopID, currencyID, productID, languageID).Scan(&products).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return products, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadProductFeatures(ctx context.Context, productID, languageID, shopID int64) ([]ProductFeature, error) {
|
||||||
|
if productID == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT pf.id_feature,
|
||||||
|
fl.name AS name,
|
||||||
|
fvl.value AS value
|
||||||
|
FROM %sfeature_product pf
|
||||||
|
LEFT JOIN %sfeature_lang fl
|
||||||
|
ON fl.id_feature = pf.id_feature
|
||||||
|
AND fl.id_lang = ?
|
||||||
|
LEFT JOIN %sfeature_value_lang fvl
|
||||||
|
ON fvl.id_feature_value = pf.id_feature_value
|
||||||
|
AND fvl.id_lang = ?
|
||||||
|
LEFT JOIN %sfeature f
|
||||||
|
ON f.id_feature = pf.id_feature
|
||||||
|
LEFT JOIN %sfeature_shop fs
|
||||||
|
ON fs.id_feature = f.id_feature
|
||||||
|
AND fs.id_shop = ?
|
||||||
|
WHERE pf.id_product = ?
|
||||||
|
ORDER BY f.position ASC
|
||||||
|
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
|
|
||||||
|
features := make([]ProductFeature, 0)
|
||||||
|
if err := s.db.WithContext(ctx).Raw(
|
||||||
|
strings.TrimSpace(query),
|
||||||
|
languageID,
|
||||||
|
languageID,
|
||||||
|
shopID,
|
||||||
|
productID,
|
||||||
|
).Scan(&features).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return features, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadProductCombinations(ctx context.Context, productID, languageID, shopID, currencyID int64) ([]ProductCombination, error) {
|
||||||
|
if productID == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type combinationRow struct {
|
||||||
|
ID int64 `gorm:"column:id_product_attribute"`
|
||||||
|
Price float64 `gorm:"column:price"`
|
||||||
|
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
|
||||||
|
DefaultOn bool `gorm:"column:default_on"`
|
||||||
|
GroupName string `gorm:"column:group_name"`
|
||||||
|
PublicName string `gorm:"column:public_group_name"`
|
||||||
|
Attribute string `gorm:"column:attribute_name"`
|
||||||
|
GroupType string `gorm:"column:group_type"`
|
||||||
|
Color string `gorm:"column:attribute_color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT pa.id_product_attribute,
|
||||||
|
((ps.price + COALESCE(pas.price, 0)) * curr.conversion_rate) AS price,
|
||||||
|
(((ps.price + COALESCE(pas.price, 0)) * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
|
||||||
|
CASE WHEN COALESCE(pas.default_on, pa.default_on, 0) = 1 THEN 1 ELSE 0 END AS default_on,
|
||||||
|
agl.name AS group_name,
|
||||||
|
agl.public_name AS public_group_name,
|
||||||
|
al.name AS attribute_name,
|
||||||
|
ag.group_type AS group_type,
|
||||||
|
a.color AS attribute_color
|
||||||
|
FROM %sproduct_attribute pa
|
||||||
|
JOIN %sproduct_shop ps
|
||||||
|
ON ps.id_product = pa.id_product
|
||||||
|
AND ps.id_shop = ?
|
||||||
|
JOIN %scurrency curr
|
||||||
|
ON curr.id_currency = ?
|
||||||
|
AND curr.deleted = 0
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tr.id_tax_rules_group,
|
||||||
|
SUM(t.rate) AS tax_rate
|
||||||
|
FROM %stax_rule tr
|
||||||
|
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||||
|
GROUP BY tr.id_tax_rules_group
|
||||||
|
) tax_data
|
||||||
|
ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
|
||||||
|
LEFT JOIN %sproduct_attribute_shop pas
|
||||||
|
ON pas.id_product_attribute = pa.id_product_attribute
|
||||||
|
AND pas.id_shop = ?
|
||||||
|
JOIN %sproduct_attribute_combination pac
|
||||||
|
ON pac.id_product_attribute = pa.id_product_attribute
|
||||||
|
JOIN %sattribute a
|
||||||
|
ON a.id_attribute = pac.id_attribute
|
||||||
|
JOIN %sattribute_lang al
|
||||||
|
ON al.id_attribute = a.id_attribute
|
||||||
|
AND al.id_lang = ?
|
||||||
|
JOIN %sattribute_group ag
|
||||||
|
ON ag.id_attribute_group = a.id_attribute_group
|
||||||
|
JOIN %sattribute_group_lang agl
|
||||||
|
ON agl.id_attribute_group = ag.id_attribute_group
|
||||||
|
AND agl.id_lang = ?
|
||||||
|
WHERE pa.id_product = ?
|
||||||
|
ORDER BY CASE WHEN COALESCE(pas.default_on, pa.default_on, 0) = 1 THEN 0 ELSE 1 END,
|
||||||
|
pa.id_product_attribute ASC,
|
||||||
|
ag.position ASC,
|
||||||
|
a.position ASC,
|
||||||
|
al.name ASC
|
||||||
|
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
|
|
||||||
|
rows := make([]combinationRow, 0)
|
||||||
|
if err := s.db.WithContext(ctx).Raw(
|
||||||
|
strings.TrimSpace(query),
|
||||||
|
shopID,
|
||||||
|
currencyID,
|
||||||
|
shopID,
|
||||||
|
languageID,
|
||||||
|
languageID,
|
||||||
|
productID,
|
||||||
|
).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
combinations := make([]ProductCombination, 0)
|
||||||
|
indexByID := make(map[int64]int)
|
||||||
|
for _, row := range rows {
|
||||||
|
idx, exists := indexByID[row.ID]
|
||||||
|
if !exists {
|
||||||
|
combinations = append(combinations, ProductCombination{
|
||||||
|
ID: row.ID,
|
||||||
|
Price: row.Price,
|
||||||
|
PriceTaxIncl: row.PriceTaxIncl,
|
||||||
|
DefaultOn: row.DefaultOn,
|
||||||
|
})
|
||||||
|
idx = len(combinations) - 1
|
||||||
|
indexByID[row.ID] = idx
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(row.GroupName) != "" || strings.TrimSpace(row.Attribute) != "" {
|
||||||
|
combinations[idx].Attributes = append(combinations[idx].Attributes, ProductCombinationAttribute{
|
||||||
|
Group: row.GroupName,
|
||||||
|
PublicName: row.PublicName,
|
||||||
|
Value: row.Attribute,
|
||||||
|
GroupType: row.GroupType,
|
||||||
|
Color: row.Color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.loadCombinationImageIDs(ctx, &combinations); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return combinations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadCombinationImageIDs(ctx context.Context, combinations *[]ProductCombination) error {
|
||||||
|
if combinations == nil || len(*combinations) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
combinationIDs := make([]int64, 0, len(*combinations))
|
||||||
|
indexByID := make(map[int64]int, len(*combinations))
|
||||||
|
for i, combination := range *combinations {
|
||||||
|
if combination.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
combinationIDs = append(combinationIDs, combination.ID)
|
||||||
|
indexByID[combination.ID] = i
|
||||||
|
}
|
||||||
|
if len(combinationIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type combinationImageRow struct {
|
||||||
|
ID int64 `gorm:"column:id_product_attribute"`
|
||||||
|
ImageID int64 `gorm:"column:id_image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT pai.id_product_attribute,
|
||||||
|
MIN(pai.id_image) AS id_image
|
||||||
|
FROM %sproduct_attribute_image pai
|
||||||
|
WHERE pai.id_product_attribute IN ?
|
||||||
|
GROUP BY pai.id_product_attribute
|
||||||
|
`, s.prefix)
|
||||||
|
|
||||||
|
rows := make([]combinationImageRow, 0)
|
||||||
|
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), combinationIDs).Scan(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
idx, exists := indexByID[row.ID]
|
||||||
|
if !exists || row.ImageID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
(*combinations)[idx].ImageID = sql.NullInt64{Int64: row.ImageID, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetCategoryPage(ctx context.Context, req CategoryPageRequest) (*CategoryPageData, error) {
|
func (s *Service) GetCategoryPage(ctx context.Context, req CategoryPageRequest) (*CategoryPageData, error) {
|
||||||
var category CategoryPageData
|
var category CategoryPageData
|
||||||
|
if req.CurrencyID == 0 {
|
||||||
|
req.CurrencyID = 1
|
||||||
|
}
|
||||||
|
if req.Page <= 0 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PerPage <= 0 {
|
||||||
|
req.PerPage = 20
|
||||||
|
}
|
||||||
categoryQuery := fmt.Sprintf(`
|
categoryQuery := fmt.Sprintf(`
|
||||||
SELECT c.id_category AS id,
|
SELECT c.id_category AS id,
|
||||||
cl.name AS name,
|
cl.name AS name,
|
||||||
@@ -213,25 +614,67 @@ LIMIT 1
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
countQuery := fmt.Sprintf(`
|
||||||
|
SELECT COUNT(*) AS total_items
|
||||||
|
FROM %scategory_product cp
|
||||||
|
JOIN %sproduct p ON p.id_product = cp.id_product
|
||||||
|
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||||
|
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
|
||||||
|
WHERE cp.id_category = ?
|
||||||
|
AND ps.active = 1
|
||||||
|
AND pl.id_lang = ?
|
||||||
|
`, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
|
|
||||||
|
var countRow struct {
|
||||||
|
TotalItems int64 `gorm:"column:total_items"`
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(countQuery), req.ShopID, category.ID, req.LanguageID).Scan(&countRow).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
category.Pagination = CategoryPagination{
|
||||||
|
Page: req.Page,
|
||||||
|
PerPage: req.PerPage,
|
||||||
|
TotalItems: countRow.TotalItems,
|
||||||
|
TotalPages: totalPages(countRow.TotalItems, req.PerPage),
|
||||||
|
}
|
||||||
|
offset := (req.Page - 1) * req.PerPage
|
||||||
|
|
||||||
productQuery := fmt.Sprintf(`
|
productQuery := fmt.Sprintf(`
|
||||||
SELECT p.id_product AS id,
|
SELECT p.id_product AS id,
|
||||||
pl.name AS name,
|
pl.name AS name,
|
||||||
pl.link_rewrite AS slug,
|
pl.link_rewrite AS slug,
|
||||||
p.ean13 AS ean13,
|
p.ean13 AS ean13,
|
||||||
ps.price AS price,
|
(ps.price * curr.conversion_rate) AS price,
|
||||||
pl.description_short AS description
|
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
|
||||||
|
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
|
||||||
|
curr.id_currency AS currency_id,
|
||||||
|
curr.iso_code AS currency_code,
|
||||||
|
curr.sign AS currency_sign,
|
||||||
|
curr.conversion_rate AS conversion_rate,
|
||||||
|
i.id_image AS cover_image_id,
|
||||||
|
pl.description_short AS short_description
|
||||||
FROM %scategory_product cp
|
FROM %scategory_product cp
|
||||||
JOIN %sproduct p ON p.id_product = cp.id_product
|
JOIN %sproduct p ON p.id_product = cp.id_product
|
||||||
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
|
||||||
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
|
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
|
||||||
|
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
|
||||||
|
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tr.id_tax_rules_group,
|
||||||
|
SUM(t.rate) AS tax_rate
|
||||||
|
FROM %stax_rule tr
|
||||||
|
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
|
||||||
|
GROUP BY tr.id_tax_rules_group
|
||||||
|
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
|
||||||
WHERE cp.id_category = ?
|
WHERE cp.id_category = ?
|
||||||
|
AND ps.active = 1
|
||||||
AND pl.id_lang = ?
|
AND pl.id_lang = ?
|
||||||
AND ps.id_shop = ?
|
|
||||||
ORDER BY cp.position ASC, p.id_product ASC
|
ORDER BY cp.position ASC, p.id_product ASC
|
||||||
LIMIT 48
|
LIMIT ?
|
||||||
`, s.prefix, s.prefix, s.prefix, s.prefix)
|
OFFSET ?
|
||||||
|
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(productQuery), category.ID, req.LanguageID, req.ShopID).Scan(&category.Products).Error; err != nil {
|
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(productQuery), req.ShopID, req.CurrencyID, category.ID, req.LanguageID, req.PerPage, offset).Scan(&category.Products).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +709,17 @@ func (s *Service) ResolveLanguageID(ctx context.Context, req *http.Request, fall
|
|||||||
return row.ID
|
return row.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func totalPages(totalItems int64, perPage int) int {
|
||||||
|
if totalItems <= 0 || perPage <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
pages := int(totalItems / int64(perPage))
|
||||||
|
if totalItems%int64(perPage) != 0 {
|
||||||
|
pages++
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetCategoryMenu(ctx context.Context, languageID int64, shopID int64) ([]MenuItem, error) {
|
func (s *Service) GetCategoryMenu(ctx context.Context, languageID int64, shopID int64) ([]MenuItem, error) {
|
||||||
rootCategoryID, err := s.rootCategoryID(ctx)
|
rootCategoryID, err := s.rootCategoryID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Config struct {
|
|||||||
PrestaShopBaseURL string
|
PrestaShopBaseURL string
|
||||||
PrestaShopProxyTarget string
|
PrestaShopProxyTarget string
|
||||||
PrestaShopVersion string
|
PrestaShopVersion string
|
||||||
|
DomainCookie string
|
||||||
PrestaShopCookieKey string
|
PrestaShopCookieKey string
|
||||||
PrestaShopCookieIV string
|
PrestaShopCookieIV string
|
||||||
PrestaShopCookieName string
|
PrestaShopCookieName string
|
||||||
@@ -48,6 +49,7 @@ func Load() (Config, error) {
|
|||||||
PrestaShopBaseURL: os.Getenv("PRESTASHOP_BASE_URL"),
|
PrestaShopBaseURL: os.Getenv("PRESTASHOP_BASE_URL"),
|
||||||
PrestaShopProxyTarget: os.Getenv("PRESTASHOP_PROXY_TARGET"),
|
PrestaShopProxyTarget: os.Getenv("PRESTASHOP_PROXY_TARGET"),
|
||||||
PrestaShopVersion: envOr("PRESTASHOP_VERSION", "1.7.3"),
|
PrestaShopVersion: envOr("PRESTASHOP_VERSION", "1.7.3"),
|
||||||
|
DomainCookie: os.Getenv("DOMAIN_COOKIE"),
|
||||||
PrestaShopCookieKey: os.Getenv("PRESTASHOP_COOKIE_KEY"),
|
PrestaShopCookieKey: os.Getenv("PRESTASHOP_COOKIE_KEY"),
|
||||||
PrestaShopCookieIV: os.Getenv("PRESTASHOP_COOKIE_IV"),
|
PrestaShopCookieIV: os.Getenv("PRESTASHOP_COOKIE_IV"),
|
||||||
PrestaShopCookieName: os.Getenv("PRESTASHOP_COOKIE_NAME"),
|
PrestaShopCookieName: os.Getenv("PRESTASHOP_COOKIE_NAME"),
|
||||||
@@ -140,7 +142,10 @@ func (c Config) DeriveCookieName(host string) string {
|
|||||||
return c.PrestaShopCookieName
|
return c.PrestaShopCookieName
|
||||||
}
|
}
|
||||||
|
|
||||||
domain := fallbackCookieHashDomain(host)
|
domain := fallbackCookieHashDomain(c.DomainCookie)
|
||||||
|
if domain == "" {
|
||||||
|
domain = fallbackCookieHashDomain(host)
|
||||||
|
}
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
domain = fallbackCookieHashDomain(c.PrestaShopBaseURL)
|
domain = fallbackCookieHashDomain(c.PrestaShopBaseURL)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,31 @@ func TestDeriveCookieNameMatchesFallbackPrestashopRule(t *testing.T) {
|
|||||||
t.Fatalf("DeriveCookieName() = %q, want %q", got, want)
|
t.Fatalf("DeriveCookieName() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeriveCookieNameUsesDomainCookieOverride(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
PrestaShopVersion: "1.7.3",
|
||||||
|
DomainCookie: ".example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := cfg.DeriveCookieName("localhost")
|
||||||
|
sum := md5.Sum([]byte("1.7.3" + "ps-s1" + "example.com"))
|
||||||
|
want := fmt.Sprintf("PrestaShop-%x", sum)
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("DeriveCookieName() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveCookieNamePrefersExplicitCookieName(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
PrestaShopVersion: "1.7.3",
|
||||||
|
DomainCookie: ".example.com",
|
||||||
|
PrestaShopCookieName: "PrestaShop-fixed",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := cfg.DeriveCookieName("localhost")
|
||||||
|
if got != "PrestaShop-fixed" {
|
||||||
|
t.Fatalf("DeriveCookieName() = %q, want %q", got, "PrestaShop-fixed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ func (c *nativeCodec) Decode(raw string) (*SessionContext, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := validatePlaintextChecksum(string(plaintext), c.cfg.CookieIV); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
values, orderedKeys := parsePlaintext(string(plaintext))
|
values, orderedKeys := parsePlaintext(string(plaintext))
|
||||||
return &SessionContext{
|
return &SessionContext{
|
||||||
@@ -129,12 +132,10 @@ func (c *nativeCodec) decryptInternal(ciphertextHex string) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
message := append(append(append([]byte{}, salt...), iv...), encrypted...)
|
message := append(append(append([]byte{}, header...), salt...), iv...)
|
||||||
if len(expectedHMAC) == macSize && !verifyHMAC(expectedHMAC, message, keys.akey) {
|
message = append(message, encrypted...)
|
||||||
// Some existing shop cookies decrypt correctly but fail MAC verification with
|
if len(expectedHMAC) != macSize || !verifyHMAC(expectedHMAC, message, keys.akey) {
|
||||||
// the same behavior observed in the reference implementation this codec ports.
|
return nil, errors.New("integrity check failed")
|
||||||
// Keep decryption permissive for compatibility, but still compute the MAC so
|
|
||||||
// the encode path emits a complete payload.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return aesCTR(encrypted, keys.ekey, iv)
|
return aesCTR(encrypted, keys.ekey, iv)
|
||||||
@@ -161,7 +162,8 @@ func (c *nativeCodec) encryptInternal(plaintext string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
message := append(append(append([]byte{}, salt...), iv...), encrypted...)
|
message := append(append(append([]byte{}, []byte(currentVersion)...), salt...), iv...)
|
||||||
|
message = append(message, encrypted...)
|
||||||
h := hmac.New(sha256.New, keys.akey)
|
h := hmac.New(sha256.New, keys.akey)
|
||||||
h.Write(message)
|
h.Write(message)
|
||||||
mac := h.Sum(nil)
|
mac := h.Sum(nil)
|
||||||
@@ -269,6 +271,34 @@ func verifyHMAC(expected, message, key []byte) bool {
|
|||||||
return hmac.Equal(h.Sum(nil), expected)
|
return hmac.Equal(h.Sum(nil), expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validatePlaintextChecksum(plaintext, cookieIV string) error {
|
||||||
|
pairs := strings.Split(plaintext, fieldSeparator)
|
||||||
|
if len(pairs) == 0 {
|
||||||
|
return errors.New("missing cookie checksum")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyPairs := pairs[:len(pairs)-1]
|
||||||
|
body := strings.Join(bodyPairs, fieldSeparator)
|
||||||
|
if body != "" {
|
||||||
|
body += fieldSeparator
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPair := pairs[len(pairs)-1]
|
||||||
|
checksumParts := strings.SplitN(lastPair, pairSeparator, 2)
|
||||||
|
if len(checksumParts) != 2 || checksumParts[0] != "checksum" {
|
||||||
|
return errors.New("missing cookie checksum")
|
||||||
|
}
|
||||||
|
if cookieIV == "" {
|
||||||
|
return errors.New("cookie iv is required for checksum validation")
|
||||||
|
}
|
||||||
|
|
||||||
|
want := fmt.Sprintf("%d", crc32.ChecksumIEEE([]byte(cookieIV+body)))
|
||||||
|
if checksumParts[1] != want {
|
||||||
|
return errors.New("cookie checksum mismatch")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func decodeHex(input string) ([]byte, error) {
|
func decodeHex(input string) ([]byte, error) {
|
||||||
if len(input)%2 != 0 {
|
if len(input)%2 != 0 {
|
||||||
return nil, errors.New("odd length hex")
|
return nil, errors.New("odd length hex")
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package cookie
|
package cookie
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/crc32"
|
"hash/crc32"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,19 +12,19 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
testCookieKey = "def000008bf3d70e7012b7493c382d561e193218d0c74ab162fb0ea8029ce20e926531b4bcf0aaec9381152e6c161f198e06918b2d1aad67cc7cf40819a51ee328c63830"
|
testCookieKey = "def000008bf3d70e7012b7493c382d561e193218d0c74ab162fb0ea8029ce20e926531b4bcf0aaec9381152e6c161f198e06918b2d1aad67cc7cf40819a51ee328c63830"
|
||||||
testCookie = "def5020099dce5cd9ecf197adb5532a74e3db2ed9cba3d59b98f365353099b710bd562efa48b6bad1ad0a12b2ee54de0fbfcc6baa0545a8234141b03bfc1fbbbb9061af5011764b9c4dfd9c0ddcad767a453e0cc24d6b4a7c524e6c49aabd66ecc390e1a964b6e81a051b171051c829542facbb36cf64fcfebf069906dcc95476578be3fe59aaae466cf70bd9c877d301d908ec3aa4f55366567f460dfefac1684ce381293e8d4138382a42716d6aaecdcc7"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNativeCodecDecodeFixture(t *testing.T) {
|
func TestNativeCodecDecodeFixture(t *testing.T) {
|
||||||
codec, err := NewCodec(Config{
|
codec, err := NewCodec(Config{
|
||||||
CookieName: "PrestaShop-test",
|
CookieName: "PrestaShop-test",
|
||||||
CookieKey: testCookieKey,
|
CookieKey: testCookieKey,
|
||||||
|
CookieIV: "vfRFMV42",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewCodec() error = %v", err)
|
t.Fatalf("NewCodec() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := codec.Decode(testCookie)
|
session, err := codec.Decode(encodeFixtureCookie(t, codec))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Decode() error = %v", err)
|
t.Fatalf("Decode() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -32,8 +35,8 @@ func TestNativeCodecDecodeFixture(t *testing.T) {
|
|||||||
if session.Values["id_currency"] != "1" {
|
if session.Values["id_currency"] != "1" {
|
||||||
t.Fatalf("id_currency = %q, want 1", session.Values["id_currency"])
|
t.Fatalf("id_currency = %q, want 1", session.Values["id_currency"])
|
||||||
}
|
}
|
||||||
if session.Values["checksum"] != "2076001436" {
|
if session.Values["checksum"] == "" {
|
||||||
t.Fatalf("checksum = %q, want 2076001436", session.Values["checksum"])
|
t.Fatalf("checksum should not be empty")
|
||||||
}
|
}
|
||||||
if session.Values["detect_language"] != "1" {
|
if session.Values["detect_language"] != "1" {
|
||||||
t.Fatalf("detect_language = %q, want 1", session.Values["detect_language"])
|
t.Fatalf("detect_language = %q, want 1", session.Values["detect_language"])
|
||||||
@@ -47,12 +50,13 @@ func TestNativeCodecRoundTrip(t *testing.T) {
|
|||||||
codec, err := NewCodec(Config{
|
codec, err := NewCodec(Config{
|
||||||
CookieName: "PrestaShop-test",
|
CookieName: "PrestaShop-test",
|
||||||
CookieKey: testCookieKey,
|
CookieKey: testCookieKey,
|
||||||
|
CookieIV: "vfRFMV42",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewCodec() error = %v", err)
|
t.Fatalf("NewCodec() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
decoded, err := codec.Decode(testCookie)
|
decoded, err := codec.Decode(encodeFixtureCookie(t, codec))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Decode() error = %v", err)
|
t.Fatalf("Decode() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -82,7 +86,7 @@ func TestNativeCodecEncodeRecomputesPrestashopChecksum(t *testing.T) {
|
|||||||
t.Fatalf("NewCodec() error = %v", err)
|
t.Fatalf("NewCodec() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
decoded, err := codec.Decode(testCookie)
|
decoded, err := codec.Decode(encodeFixtureCookie(t, codec))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Decode() error = %v", err)
|
t.Fatalf("Decode() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -112,3 +116,188 @@ func TestNativeCodecEncodeRecomputesPrestashopChecksum(t *testing.T) {
|
|||||||
t.Fatalf("checksum = %q, want %q", got, wantChecksum)
|
t.Fatalf("checksum = %q, want %q", got, wantChecksum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNativeCodecRoundTripIsPhpDecryptable(t *testing.T) {
|
||||||
|
codec, err := NewCodec(Config{
|
||||||
|
CookieName: "PrestaShop-test",
|
||||||
|
CookieKey: testCookieKey,
|
||||||
|
CookieIV: "vfRFMV42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCodec() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &SessionContext{
|
||||||
|
Values: map[string]string{
|
||||||
|
"date_add": "2026-05-13 18:51:06",
|
||||||
|
"id_lang": "5",
|
||||||
|
"id_language": "5",
|
||||||
|
"iso_code_country": "CZ",
|
||||||
|
"id_currency": "1",
|
||||||
|
"id_guest": "39160640",
|
||||||
|
"id_connections": "13279441",
|
||||||
|
},
|
||||||
|
OrderedKeys: []string{
|
||||||
|
"date_add",
|
||||||
|
"id_lang",
|
||||||
|
"id_language",
|
||||||
|
"iso_code_country",
|
||||||
|
"id_currency",
|
||||||
|
"id_guest",
|
||||||
|
"id_connections",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := codec.Encode(session)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := hex.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hex.DecodeString() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(raw) < headerSize+saltSize+ivSize+macSize {
|
||||||
|
t.Fatalf("ciphertext too short: %d", len(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
header := raw[:headerSize]
|
||||||
|
salt := raw[headerSize : headerSize+saltSize]
|
||||||
|
iv := raw[headerSize+saltSize : headerSize+saltSize+ivSize]
|
||||||
|
hmacStart := len(raw) - macSize
|
||||||
|
encrypted := raw[headerSize+saltSize+ivSize : hmacStart]
|
||||||
|
gotMAC := raw[hmacStart:]
|
||||||
|
|
||||||
|
native := codec.(*nativeCodec)
|
||||||
|
keys, err := native.deriveKeys(salt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deriveKeys() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
message := append(append(append([]byte{}, header...), salt...), iv...)
|
||||||
|
message = append(message, encrypted...)
|
||||||
|
h := hmac.New(sha256.New, keys.akey)
|
||||||
|
h.Write(message)
|
||||||
|
wantMAC := h.Sum(nil)
|
||||||
|
if !hmac.Equal(gotMAC, wantMAC) {
|
||||||
|
t.Fatalf("MAC mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
redecoded, err := codec.Decode(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode(encoded) error = %v", err)
|
||||||
|
}
|
||||||
|
if redecoded.Plaintext != "date_add|2026-05-13 18:51:06¤id_lang|5¤id_language|5¤iso_code_country|CZ¤id_currency|1¤id_guest|39160640¤id_connections|13279441¤checksum|181610492" {
|
||||||
|
t.Fatalf("unexpected plaintext = %q", redecoded.Plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeCodecRejectsTamperedCiphertext(t *testing.T) {
|
||||||
|
codec, err := NewCodec(Config{
|
||||||
|
CookieName: "PrestaShop-test",
|
||||||
|
CookieKey: testCookieKey,
|
||||||
|
CookieIV: "vfRFMV42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCodec() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := codec.Decode(encodeFixtureCookie(t, codec))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := codec.Encode(decoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := hex.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hex.DecodeString() error = %v", err)
|
||||||
|
}
|
||||||
|
raw[len(raw)-1] ^= 0x01
|
||||||
|
tampered := hex.EncodeToString(raw)
|
||||||
|
|
||||||
|
if _, err := codec.Decode(tampered); err == nil {
|
||||||
|
t.Fatalf("Decode(tampered) error = nil, want integrity failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeCodecRejectsTamperedPlaintextChecksum(t *testing.T) {
|
||||||
|
codec, err := NewCodec(Config{
|
||||||
|
CookieName: "PrestaShop-test",
|
||||||
|
CookieKey: testCookieKey,
|
||||||
|
CookieIV: "vfRFMV42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCodec() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
native := codec.(*nativeCodec)
|
||||||
|
plaintext := "date_add|2026-05-13 18:51:06¤id_lang|5¤id_language|5¤iso_code_country|CZ¤id_currency|9¤id_guest|39160640¤id_connections|13279441¤checksum|181610492"
|
||||||
|
encoded, err := native.encryptInternal(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encryptInternal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := codec.Decode(encoded); err == nil {
|
||||||
|
t.Fatalf("Decode() error = nil, want checksum mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSerializeCookieValuesMatchesPrestashopChecksumFormula(t *testing.T) {
|
||||||
|
values := map[string]string{
|
||||||
|
"date_add": "2026-05-13 18:51:06",
|
||||||
|
"id_lang": "5",
|
||||||
|
"id_language": "5",
|
||||||
|
"iso_code_country": "CZ",
|
||||||
|
"id_currency": "1",
|
||||||
|
"id_guest": "39160640",
|
||||||
|
"id_connections": "13279441",
|
||||||
|
"checksum": "stale",
|
||||||
|
}
|
||||||
|
orderedKeys := []string{
|
||||||
|
"date_add",
|
||||||
|
"id_lang",
|
||||||
|
"id_language",
|
||||||
|
"iso_code_country",
|
||||||
|
"id_currency",
|
||||||
|
"id_guest",
|
||||||
|
"id_connections",
|
||||||
|
"checksum",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := serializeCookieValues(values, orderedKeys, "vfRFMV42")
|
||||||
|
want := "date_add|2026-05-13 18:51:06¤id_lang|5¤id_language|5¤iso_code_country|CZ¤id_currency|1¤id_guest|39160640¤id_connections|13279441¤checksum|181610492"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("serializeCookieValues() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeFixtureCookie(t *testing.T, codec Codec) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
session := &SessionContext{
|
||||||
|
Values: map[string]string{
|
||||||
|
"id_lang": "1",
|
||||||
|
"id_cart": "",
|
||||||
|
"id_language": "1",
|
||||||
|
"detect_language": "1",
|
||||||
|
"id_currency": "1",
|
||||||
|
},
|
||||||
|
OrderedKeys: []string{
|
||||||
|
"id_lang",
|
||||||
|
"id_cart",
|
||||||
|
"id_language",
|
||||||
|
"detect_language",
|
||||||
|
"id_currency",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := codec.Encode(session)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v", err)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ func (r *CategoryRoute) Match(path string) (slug string, ok bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *CategoryRoute) MatchInfo(path string) (*CategoryMatch, bool) {
|
func (r *CategoryRoute) MatchInfo(path string) (*CategoryMatch, bool) {
|
||||||
|
if hasExcludedStaticSegment(path) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
if r == nil || r.regex == nil {
|
if r == nil || r.regex == nil {
|
||||||
return fallbackCategoryMatch(path)
|
return fallbackCategoryMatch(path)
|
||||||
}
|
}
|
||||||
@@ -262,6 +265,9 @@ func (r *ProductRoute) Match(path string) (slug string, ok bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ProductRoute) MatchInfo(path string) (*ProductMatch, bool) {
|
func (r *ProductRoute) MatchInfo(path string) (*ProductMatch, bool) {
|
||||||
|
if hasExcludedStaticSegment(path) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
if r == nil || r.regex == nil {
|
if r == nil || r.regex == nil {
|
||||||
return fallbackProductMatch(path)
|
return fallbackProductMatch(path)
|
||||||
}
|
}
|
||||||
@@ -500,6 +506,9 @@ func fallbackProductMatch(path string) (*ProductMatch, bool) {
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
if hasExcludedStaticSegment(path) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
if hasExcludedContentSegment(path) {
|
if hasExcludedContentSegment(path) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -543,6 +552,9 @@ func fallbackCategoryMatch(path string) (*CategoryMatch, bool) {
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
if hasExcludedStaticSegment(path) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
if hasExcludedContentSegment(path) {
|
if hasExcludedContentSegment(path) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -601,3 +613,19 @@ func hasExcludedContentSegment(path string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasExcludedStaticSegment(path string) bool {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
path = strings.Trim(path, "/")
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
first := path
|
||||||
|
if idx := strings.IndexByte(first, '/'); idx >= 0 {
|
||||||
|
first = first[:idx]
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(first), "img")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCategoryRouteDoesNotOwnImagePath(t *testing.T) {
|
||||||
|
route, err := CompileCategoryRoute("/{id}-{rewrite}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile category route: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if match, ok := route.MatchInfo("/img/p/1/1/9/6/1/2/119612-large_default.webp"); ok || match != nil {
|
||||||
|
t.Fatalf("expected image path to bypass category route, got ok=%v match=%+v", ok, match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProductRouteDoesNotOwnImagePath(t *testing.T) {
|
||||||
|
route, err := CompileProductRoute("/{id}-{rewrite}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile product route: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if match, ok := route.MatchInfo("/img/p/1/1/9/6/1/2/119612-large_default.webp"); ok || match != nil {
|
||||||
|
t.Fatalf("expected image path to bypass product route, got ok=%v match=%+v", ok, match)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ type Service struct {
|
|||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
prefix string
|
prefix string
|
||||||
version string
|
version string
|
||||||
|
explicitCookieName string
|
||||||
|
domainCookie string
|
||||||
}
|
}
|
||||||
|
|
||||||
type defaults struct {
|
type defaults struct {
|
||||||
@@ -33,8 +35,14 @@ type defaults struct {
|
|||||||
CookieHours int64
|
CookieHours int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(db *gorm.DB, prefix, version string) *Service {
|
func NewService(db *gorm.DB, prefix, version, explicitCookieName, domainCookie string) *Service {
|
||||||
return &Service{db: db, prefix: prefix, version: version}
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
prefix: prefix,
|
||||||
|
version: version,
|
||||||
|
explicitCookieName: explicitCookieName,
|
||||||
|
domainCookie: domainCookie,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) NewAnonymous(ctx context.Context, req *http.Request, cookieName string) (*pscookie.SessionContext, error) {
|
func (s *Service) NewAnonymous(ctx context.Context, req *http.Request, cookieName string) (*pscookie.SessionContext, error) {
|
||||||
@@ -115,15 +123,19 @@ func (s *Service) RefreshExpiry(ctx context.Context, session *pscookie.SessionCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ResolveCookieName(ctx context.Context, req *http.Request) (string, error) {
|
func (s *Service) ResolveCookieName(ctx context.Context, req *http.Request) (string, error) {
|
||||||
|
if s != nil && strings.TrimSpace(s.explicitCookieName) != "" {
|
||||||
|
return s.explicitCookieName, nil
|
||||||
|
}
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return "", fmt.Errorf("prestashop session service is not initialized")
|
return "", fmt.Errorf("prestashop session service is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
host := requestHost(req)
|
requestedHost := requestHost(req)
|
||||||
shop, err := s.loadCookieShopContext(ctx, req)
|
shop, err := s.loadCookieShopContext(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
host := cookieDomainSource(shop, requestedHost)
|
||||||
|
|
||||||
baseName := "ps-s" + strconv.FormatInt(shop.ShopID, 10)
|
baseName := "ps-s" + strconv.FormatInt(shop.ShopID, 10)
|
||||||
sharedDomains := []string(nil)
|
sharedDomains := []string(nil)
|
||||||
@@ -135,10 +147,32 @@ func (s *Service) ResolveCookieName(ctx context.Context, req *http.Request) (str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sum := md5.Sum([]byte(s.version + baseName + prestashopCookieDomain(host, sharedDomains)))
|
domain := overrideCookieHashDomain(s.domainCookie)
|
||||||
|
if domain == "" {
|
||||||
|
domain = prestashopCookieDomain(host, sharedDomains)
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := md5.Sum([]byte(s.version + baseName + domain))
|
||||||
return fmt.Sprintf("PrestaShop-%x", sum), nil
|
return fmt.Sprintf("PrestaShop-%x", sum), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ResolveCookiePath(ctx context.Context, req *http.Request) (string, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return "", fmt.Errorf("prestashop session service is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
shop, err := s.loadCookieShopContext(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
path := normalizeCookiePath(shop.PhysicalURI)
|
||||||
|
if path == "" {
|
||||||
|
return "/", nil
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) loadDefaults(ctx context.Context) (*defaults, error) {
|
func (s *Service) loadDefaults(ctx context.Context) (*defaults, error) {
|
||||||
def := &defaults{
|
def := &defaults{
|
||||||
LanguageID: 1,
|
LanguageID: 1,
|
||||||
@@ -334,6 +368,9 @@ type cookieShopContext struct {
|
|||||||
ShopID int64 `gorm:"column:id_shop"`
|
ShopID int64 `gorm:"column:id_shop"`
|
||||||
ShopGroupID int64 `gorm:"column:id_shop_group"`
|
ShopGroupID int64 `gorm:"column:id_shop_group"`
|
||||||
ShareOrder bool `gorm:"column:share_order"`
|
ShareOrder bool `gorm:"column:share_order"`
|
||||||
|
Domain string `gorm:"column:domain"`
|
||||||
|
DomainSSL string `gorm:"column:domain_ssl"`
|
||||||
|
PhysicalURI string `gorm:"column:physical_uri"`
|
||||||
URI string `gorm:"column:uri"`
|
URI string `gorm:"column:uri"`
|
||||||
Main bool `gorm:"column:main"`
|
Main bool `gorm:"column:main"`
|
||||||
}
|
}
|
||||||
@@ -348,7 +385,9 @@ func (s *Service) loadCookieShopContext(ctx context.Context, req *http.Request)
|
|||||||
|
|
||||||
if normalizedHost != "" {
|
if normalizedHost != "" {
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
SELECT s.id_shop, s.id_shop_group, sg.share_order, CONCAT(su.physical_uri, su.virtual_uri) AS uri, su.main
|
SELECT s.id_shop, s.id_shop_group, sg.share_order, su.domain, su.domain_ssl,
|
||||||
|
su.physical_uri,
|
||||||
|
CONCAT(su.physical_uri, su.virtual_uri) AS uri, su.main
|
||||||
FROM %s s
|
FROM %s s
|
||||||
JOIN %s sg ON sg.id_shop_group = s.id_shop_group
|
JOIN %s sg ON sg.id_shop_group = s.id_shop_group
|
||||||
JOIN %s su ON su.id_shop = s.id_shop
|
JOIN %s su ON su.id_shop = s.id_shop
|
||||||
@@ -370,14 +409,18 @@ ORDER BY LENGTH(CONCAT(su.physical_uri, su.virtual_uri)) DESC, su.main DESC, s.i
|
|||||||
}
|
}
|
||||||
|
|
||||||
fallbackQuery := fmt.Sprintf(`
|
fallbackQuery := fmt.Sprintf(`
|
||||||
SELECT s.id_shop, s.id_shop_group, sg.share_order, '' AS uri, 1 AS main
|
SELECT s.id_shop, s.id_shop_group, sg.share_order, su.domain, su.domain_ssl,
|
||||||
|
su.physical_uri,
|
||||||
|
'' AS uri, su.main
|
||||||
FROM %s s
|
FROM %s s
|
||||||
JOIN %s sg ON sg.id_shop_group = s.id_shop_group
|
JOIN %s sg ON sg.id_shop_group = s.id_shop_group
|
||||||
|
JOIN %s su ON su.id_shop = s.id_shop
|
||||||
WHERE s.active = 1
|
WHERE s.active = 1
|
||||||
AND s.deleted = 0
|
AND s.deleted = 0
|
||||||
ORDER BY s.id_shop ASC
|
AND su.active = 1
|
||||||
|
ORDER BY su.main DESC, s.id_shop ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, shopTable, shopGroupTable)
|
`, shopTable, shopGroupTable, shopURLTable)
|
||||||
var shop cookieShopContext
|
var shop cookieShopContext
|
||||||
if err := s.db.WithContext(ctx).Raw(fallbackQuery).Scan(&shop).Error; err != nil {
|
if err := s.db.WithContext(ctx).Raw(fallbackQuery).Scan(&shop).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -511,6 +554,50 @@ func prestashopCookieDomain(host string, sharedURLs []string) string {
|
|||||||
return normalizedHost
|
return normalizedHost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func overrideCookieHashDomain(input string) string {
|
||||||
|
value := strings.TrimSpace(strings.ToLower(input))
|
||||||
|
value = strings.TrimPrefix(value, ".")
|
||||||
|
value = strings.TrimPrefix(value, "www.")
|
||||||
|
if value == "" || net.ParseIP(value) != nil || !strings.Contains(value, ".") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCookiePath(input string) string {
|
||||||
|
value := strings.TrimSpace(input)
|
||||||
|
if value == "" || value == "/" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
value = "/" + strings.Trim(value, "/") + "/"
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func cookieDomainSource(shop *cookieShopContext, requestedHost string) string {
|
||||||
|
if shop == nil {
|
||||||
|
return requestedHost
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedHost = normalizeRequestHost(requestedHost)
|
||||||
|
domain := normalizeRequestHost(shop.Domain)
|
||||||
|
domainSSL := normalizeRequestHost(shop.DomainSSL)
|
||||||
|
|
||||||
|
switch requestedHost {
|
||||||
|
case domainSSL:
|
||||||
|
return domainSSL
|
||||||
|
case domain:
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
|
||||||
|
if domainSSL != "" {
|
||||||
|
return domainSSL
|
||||||
|
}
|
||||||
|
if domain != "" {
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
return requestedHost
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) tableColumns(ctx context.Context, tableName string) (map[string]bool, error) {
|
func (s *Service) tableColumns(ctx context.Context, tableName string) (map[string]bool, error) {
|
||||||
type columnRow struct {
|
type columnRow struct {
|
||||||
ColumnName string `gorm:"column:COLUMN_NAME"`
|
ColumnName string `gorm:"column:COLUMN_NAME"`
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package session
|
package session
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestPrestashopCookieDomain(t *testing.T) {
|
func TestPrestashopCookieDomain(t *testing.T) {
|
||||||
if got := prestashopCookieDomain("localhost", nil); got != "" {
|
if got := prestashopCookieDomain("localhost", nil); got != "" {
|
||||||
@@ -25,3 +31,61 @@ func TestURIMatchesRequest(t *testing.T) {
|
|||||||
t.Fatalf("unexpected match for different shop URI")
|
t.Fatalf("unexpected match for different shop URI")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCookieDomainSourcePrefersDatabaseDomain(t *testing.T) {
|
||||||
|
shop := &cookieShopContext{
|
||||||
|
Domain: "shop.example.com",
|
||||||
|
DomainSSL: "secure.example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := cookieDomainSource(shop, "proxy.internal"); got != "secure.example.com" {
|
||||||
|
t.Fatalf("cookieDomainSource() = %q, want %q", got, "secure.example.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCookieDomainSourceKeepsMatchingDatabaseHost(t *testing.T) {
|
||||||
|
shop := &cookieShopContext{
|
||||||
|
Domain: "shop.example.com",
|
||||||
|
DomainSSL: "secure.example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := cookieDomainSource(shop, "shop.example.com"); got != "shop.example.com" {
|
||||||
|
t.Fatalf("cookieDomainSource() = %q, want %q", got, "shop.example.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverrideCookieHashDomain(t *testing.T) {
|
||||||
|
if got := overrideCookieHashDomain(".Example.com"); got != "example.com" {
|
||||||
|
t.Fatalf("overrideCookieHashDomain() = %q, want %q", got, "example.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCookieNameReturnsExplicitOverride(t *testing.T) {
|
||||||
|
service := NewService(nil, "ps_", "1.7.3", "PrestaShop-fixed", "")
|
||||||
|
|
||||||
|
got, err := service.ResolveCookieName(context.Background(), httptest.NewRequest("GET", "https://shop.example.com/", nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveCookieName() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != "PrestaShop-fixed" {
|
||||||
|
t.Fatalf("ResolveCookieName() = %q, want %q", got, "PrestaShop-fixed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainCookieOverrideParticipatesInHash(t *testing.T) {
|
||||||
|
sum := md5.Sum([]byte("1.7.3" + "ps-s1" + overrideCookieHashDomain(".example.com")))
|
||||||
|
got := fmt.Sprintf("PrestaShop-%x", sum)
|
||||||
|
want := "PrestaShop-1e5aa4f42a55532134a4e84017cdf643"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("derived cookie name = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCookiePath(t *testing.T) {
|
||||||
|
if got := normalizeCookiePath(""); got != "/" {
|
||||||
|
t.Fatalf("normalizeCookiePath(\"\") = %q, want %q", got, "/")
|
||||||
|
}
|
||||||
|
if got := normalizeCookiePath("shop"); got != "/shop/" {
|
||||||
|
t.Fatalf("normalizeCookiePath(\"shop\") = %q, want %q", got, "/shop/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
package render
|
package render
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
"git.ma-al.com/goc_marek/ps_shop/internal/assets"
|
"git.ma-al.com/goc_marek/ps_shop/internal/assets"
|
||||||
"git.ma-al.com/goc_marek/ps_shop/internal/viewmodel"
|
"git.ma-al.com/goc_marek/ps_shop/internal/viewmodel"
|
||||||
"git.ma-al.com/goc_marek/ps_shop/templates"
|
"git.ma-al.com/goc_marek/ps_shop/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Minimize templ's internal buffering so HTML reaches the client as it is rendered.
|
||||||
|
templruntime.DefaultBufferSize = 1
|
||||||
|
}
|
||||||
|
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
assets assets.Manifest
|
assets assets.Manifest
|
||||||
}
|
}
|
||||||
@@ -19,13 +28,19 @@ func New(manifest assets.Manifest) *Engine {
|
|||||||
func (e *Engine) Product(w http.ResponseWriter, r *http.Request, data viewmodel.ProductPageData) error {
|
func (e *Engine) Product(w http.ResponseWriter, r *http.Request, data viewmodel.ProductPageData) error {
|
||||||
startHTMLStream(w)
|
startHTMLStream(w)
|
||||||
component := templates.ProductPage(data, e.assets.CSSPath("app.css"), e.assets.JSPath("app.js"))
|
component := templates.ProductPage(data, e.assets.CSSPath("app.css"), e.assets.JSPath("app.js"))
|
||||||
return component.Render(r.Context(), w)
|
return streamComponent(r.Context(), w, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) Category(w http.ResponseWriter, r *http.Request, data viewmodel.CategoryPageData) error {
|
func (e *Engine) Category(w http.ResponseWriter, r *http.Request, data viewmodel.CategoryPageData) error {
|
||||||
startHTMLStream(w)
|
startHTMLStream(w)
|
||||||
component := templates.CategoryPage(data, e.assets.CSSPath("app.css"), e.assets.JSPath("app.js"))
|
component := templates.CategoryPage(data, e.assets.CSSPath("app.css"), e.assets.JSPath("app.js"))
|
||||||
return component.Render(r.Context(), w)
|
return streamComponent(r.Context(), w, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Cart(w http.ResponseWriter, r *http.Request, data viewmodel.CartPageData) error {
|
||||||
|
startHTMLStream(w)
|
||||||
|
component := templates.CartPage(data, e.assets.CSSPath("app.css"), e.assets.JSPath("app.js"))
|
||||||
|
return streamComponent(r.Context(), w, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
func startHTMLStream(w http.ResponseWriter) {
|
func startHTMLStream(w http.ResponseWriter) {
|
||||||
@@ -41,3 +56,15 @@ func startHTMLStream(w http.ResponseWriter) {
|
|||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func streamComponent(ctx context.Context, w http.ResponseWriter, component templ.Component) error {
|
||||||
|
if component == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var buffer templruntime.Buffer
|
||||||
|
buffer.Reset(w)
|
||||||
|
if err := component.Render(ctx, &buffer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return buffer.Flush()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
|
type writeCountingResponseWriter struct {
|
||||||
|
header http.Header
|
||||||
|
writes int
|
||||||
|
body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *writeCountingResponseWriter) Header() http.Header {
|
||||||
|
if w.header == nil {
|
||||||
|
w.header = make(http.Header)
|
||||||
|
}
|
||||||
|
return w.header
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *writeCountingResponseWriter) Write(p []byte) (int, error) {
|
||||||
|
w.writes++
|
||||||
|
w.body = append(w.body, p...)
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *writeCountingResponseWriter) WriteHeader(statusCode int) {}
|
||||||
|
|
||||||
|
func (w *writeCountingResponseWriter) Flush() {}
|
||||||
|
|
||||||
|
func TestStreamComponentWritesIncrementally(t *testing.T) {
|
||||||
|
w := &writeCountingResponseWriter{}
|
||||||
|
component := templ.ComponentFunc(func(ctx context.Context, writer io.Writer) error {
|
||||||
|
if _, err := writer.Write([]byte("a")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := writer.Write([]byte("b")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := streamComponent(context.Background(), w, component); err != nil {
|
||||||
|
t.Fatalf("streamComponent() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := string(w.body); got != "ab" {
|
||||||
|
t.Fatalf("body = %q, want %q", got, "ab")
|
||||||
|
}
|
||||||
|
if w.writes < 2 {
|
||||||
|
t.Fatalf("writes = %d, want at least 2 incremental writes", w.writes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package viewmodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
pscart "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cart"
|
||||||
|
pscatalog "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/catalog"
|
||||||
|
pscookie "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cookie"
|
||||||
|
pscustomer "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/customer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CartPageData struct {
|
||||||
|
Cart pscart.Page
|
||||||
|
Menu []pscatalog.MenuItem
|
||||||
|
Locale pscatalog.HeaderLocaleData
|
||||||
|
Session *pscookie.SessionContext
|
||||||
|
Customer *pscustomer.Profile
|
||||||
|
CartSummary *pscart.Summary
|
||||||
|
ShopBaseURL string
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
type CategoryPageData struct {
|
type CategoryPageData struct {
|
||||||
Category pscatalog.CategoryPageData
|
Category pscatalog.CategoryPageData
|
||||||
|
Pagination CategoryPagination
|
||||||
Menu []pscatalog.MenuItem
|
Menu []pscatalog.MenuItem
|
||||||
Locale pscatalog.HeaderLocaleData
|
Locale pscatalog.HeaderLocaleData
|
||||||
Session *pscookie.SessionContext
|
Session *pscookie.SessionContext
|
||||||
@@ -16,3 +17,12 @@ type CategoryPageData struct {
|
|||||||
CartSummary *pscart.Summary
|
CartSummary *pscart.Summary
|
||||||
ShopBaseURL string
|
ShopBaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CategoryPagination struct {
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
TotalItems int64
|
||||||
|
TotalPages int
|
||||||
|
PrevURL string
|
||||||
|
NextURL string
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.ma-al.com/goc_marek/ps_shop/internal/viewmodel"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ CartPage(data viewmodel.CartPageData, cssPath string, jsPath string) {
|
||||||
|
@Layout("Cart", cssPath, jsPath, data.Menu, data.Locale, data.Cart.TotalItems) {
|
||||||
|
<main class="min-h-screen bg-[#fdfbf7]">
|
||||||
|
<div class="site-container flex flex-col gap-8 py-6 sm:py-8 lg:py-10">
|
||||||
|
<header class="border-b border-stone-200 pb-6">
|
||||||
|
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div class="max-w-3xl">
|
||||||
|
<p class="text-[0.72rem] font-semibold uppercase tracking-[0.26em] text-stone-400">Cart overview</p>
|
||||||
|
<h1 class="mt-4 text-3xl font-medium text-stone-800 sm:text-[2.5rem]">Everything ready for checkout.</h1>
|
||||||
|
<p class="mt-4 max-w-2xl text-sm leading-7 text-stone-500">Review quantities, adjust line items, and confirm the final order state before checkout continues in PrestaShop.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 lg:min-w-[20rem]">
|
||||||
|
<div class="border border-stone-200 bg-white px-5 py-4 shadow-[0_12px_30px_rgba(20,33,61,0.05)]">
|
||||||
|
<p class="text-[0.68rem] uppercase tracking-[0.28em] text-stone-500">Line items</p>
|
||||||
|
<p class="mt-2 text-3xl font-semibold text-stone-900">{ fmt.Sprintf("%d", len(data.Cart.Items)) }</p>
|
||||||
|
</div>
|
||||||
|
<div class="border border-stone-200 bg-[#fff7e7] px-5 py-4 shadow-[0_12px_30px_rgba(20,33,61,0.05)]">
|
||||||
|
<p class="text-[0.68rem] uppercase tracking-[0.28em] text-stone-500">Units total</p>
|
||||||
|
<p class="mt-2 text-3xl font-semibold text-stone-900">{ fmt.Sprintf("%d", data.Cart.TotalItems) }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex flex-wrap items-center gap-3 text-sm text-stone-500">
|
||||||
|
if data.Customer != nil {
|
||||||
|
<span class="border border-stone-200 bg-white px-4 py-2">{ fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName) }</span>
|
||||||
|
} else {
|
||||||
|
<span class="border border-stone-200 bg-white px-4 py-2">Guest session</span>
|
||||||
|
}
|
||||||
|
<span class="border border-stone-200 bg-white px-4 py-2">{ "Subtotal " + moneyWithCurrency(data.Cart.SubtotalTaxIncl, cartCurrencySign(data), cartCurrencyCode(data)) }</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid gap-8 xl:grid-cols-[minmax(0,1.55fr)_24rem]">
|
||||||
|
<div class="border border-stone-200 bg-white p-5 shadow-[0_18px_42px_rgba(20,33,61,0.06)] lg:p-6">
|
||||||
|
if len(data.Cart.Items) == 0 {
|
||||||
|
<div class="border border-dashed border-stone-300 bg-[#fcfbf8] p-12 text-center">
|
||||||
|
<p class="text-sm uppercase tracking-[0.28em] text-stone-400">Empty</p>
|
||||||
|
<p class="mt-4 text-3xl font-semibold text-stone-900">Your cart is empty.</p>
|
||||||
|
<p class="mx-auto mt-3 max-w-md text-sm leading-7 text-stone-500">Browse categories or return to a product page to add items and build a full order before checkout.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="mb-4 hidden grid-cols-[minmax(0,1fr)_11rem] gap-4 px-4 text-[0.68rem] uppercase tracking-[0.28em] text-stone-500 md:grid">
|
||||||
|
<div>Product</div>
|
||||||
|
<div class="text-right">Summary</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
for _, item := range data.Cart.Items {
|
||||||
|
<article class="border border-stone-200 bg-[#fffdfa] p-5 transition hover:border-amber-400/60 md:p-6">
|
||||||
|
<div class="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
||||||
|
if item.ImageURL != "" {
|
||||||
|
<div class="flex overflow-hidden border border-stone-200 bg-white md:w-32 md:shrink-0 md:items-center md:justify-center">
|
||||||
|
<img class="block h-32 w-full object-contain" src={ item.ImageURL } alt={ item.Name }/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<p class="text-[0.68rem] uppercase tracking-[0.3em] text-stone-400">Product</p>
|
||||||
|
if cartItemAttributeLabel(item) != "" {
|
||||||
|
<span class="border border-amber-200 bg-[#fff7e7] px-2.5 py-1 text-[0.65rem] uppercase tracking-[0.16em] text-amber-700">{ cartItemAttributeLabel(item) }</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if item.URL != "" {
|
||||||
|
<a class="mt-3 block truncate text-2xl font-semibold text-stone-900 underline-offset-4 transition hover:text-amber-600 hover:underline" href={ item.URL }>{ item.Name }</a>
|
||||||
|
} else {
|
||||||
|
<h2 class="mt-3 text-2xl font-semibold text-stone-900">{ item.Name }</h2>
|
||||||
|
}
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2 text-xs text-stone-500">
|
||||||
|
<span class="border border-stone-200 bg-white px-3 py-1.5">{ fmt.Sprintf("Qty %d", item.Quantity) }</span>
|
||||||
|
<span class="border border-stone-200 bg-white px-3 py-1.5">{ "Net " + moneyWithCurrency(item.UnitPrice, item.CurrencySign, item.CurrencyCode) }</span>
|
||||||
|
<span class="border border-stone-200 bg-white px-3 py-1.5">{ taxLabel(item.TaxRate) }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border border-stone-200 bg-white px-4 py-4 text-left md:min-w-[11rem] md:text-right">
|
||||||
|
<p class="text-[0.68rem] uppercase tracking-[0.24em] text-stone-500">Line total</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-stone-900">{ moneyWithCurrency(item.LineTotalTaxIncl, item.CurrencySign, item.CurrencyCode) }</p>
|
||||||
|
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-stone-500">{ conversionRateLabel(item.ConversionRate, item.CurrencyCode) }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex flex-col gap-3 border-t border-stone-200 pt-5 md:flex-row md:items-center md:justify-between">
|
||||||
|
<form class="flex flex-wrap items-center gap-3" method="post" action={ localizedCartPath(data.Locale) }>
|
||||||
|
<input type="hidden" name="action" value="update"/>
|
||||||
|
<input type="hidden" name="id_product" value={ fmt.Sprintf("%d", item.ProductID) }/>
|
||||||
|
<input type="hidden" name="id_product_attribute" value={ fmt.Sprintf("%d", item.ProductAttributeID) }/>
|
||||||
|
<input type="hidden" name="id_customization" value={ fmt.Sprintf("%d", item.CustomizationID) }/>
|
||||||
|
<label class="text-[0.75rem] uppercase tracking-[0.24em] text-stone-400" for={ fmt.Sprintf("qty-%d-%d", item.ProductID, item.ProductAttributeID) }>Quantity</label>
|
||||||
|
<div class="flex items-center border border-stone-300 bg-white">
|
||||||
|
<input class="w-20 bg-transparent px-4 py-2.5 text-center text-sm text-stone-900 outline-none ring-0" id={ fmt.Sprintf("qty-%d-%d", item.ProductID, item.ProductAttributeID) } type="number" min="1" name="qty" value={ fmt.Sprintf("%d", item.Quantity) }/>
|
||||||
|
</div>
|
||||||
|
<button class="border border-amber-500 bg-amber-500 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.22em] text-white transition hover:bg-amber-600" type="submit">
|
||||||
|
Update line
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action={ localizedCartPath(data.Locale) }>
|
||||||
|
<input type="hidden" name="action" value="delete"/>
|
||||||
|
<input type="hidden" name="id_product" value={ fmt.Sprintf("%d", item.ProductID) }/>
|
||||||
|
<input type="hidden" name="id_product_attribute" value={ fmt.Sprintf("%d", item.ProductAttributeID) }/>
|
||||||
|
<input type="hidden" name="id_customization" value={ fmt.Sprintf("%d", item.CustomizationID) }/>
|
||||||
|
<button class="border border-stone-300 bg-white px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.22em] text-stone-700 transition hover:border-rose-300 hover:bg-rose-50 hover:text-rose-700" type="submit">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="xl:sticky xl:top-28 xl:self-start">
|
||||||
|
<div class="border border-stone-200 bg-white p-8 shadow-[0_18px_42px_rgba(20,33,61,0.06)]">
|
||||||
|
<p class="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-stone-400">Summary</p>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-stone-500">A compact snapshot of the current order before checkout moves to the native PrestaShop flow.</p>
|
||||||
|
<div class="mt-8 space-y-3">
|
||||||
|
<div class="flex items-center justify-between border border-stone-200 bg-[#fcfbf8] px-4 py-3 text-sm text-stone-700">
|
||||||
|
<span>Total items</span>
|
||||||
|
<span class="font-semibold text-stone-900">{ fmt.Sprintf("%d", data.Cart.TotalItems) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border border-stone-200 bg-[#fcfbf8] px-4 py-3 text-sm text-stone-700">
|
||||||
|
<span>Unique lines</span>
|
||||||
|
<span class="font-semibold text-stone-900">{ fmt.Sprintf("%d", len(data.Cart.Items)) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border border-stone-200 bg-[#fcfbf8] px-4 py-3 text-sm text-stone-700">
|
||||||
|
<span>Subtotal net</span>
|
||||||
|
<span class="font-semibold text-stone-900">{ moneyWithCurrency(data.Cart.Subtotal, cartCurrencySign(data), cartCurrencyCode(data)) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border border-amber-200 bg-[#fff7e7] px-5 py-4 text-lg font-semibold text-stone-900">
|
||||||
|
<span>Subtotal gross</span>
|
||||||
|
<span>{ moneyWithCurrency(data.Cart.SubtotalTaxIncl, cartCurrencySign(data), cartCurrencyCode(data)) }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="mt-8 inline-flex w-full items-center justify-center bg-amber-500 px-5 py-3.5 text-sm font-semibold uppercase tracking-[0.22em] text-white transition hover:bg-amber-600" href={ data.ShopBaseURL + "/order" }>
|
||||||
|
Checkout in PrestaShop
|
||||||
|
</a>
|
||||||
|
<a class="mt-3 inline-flex w-full items-center justify-center border border-stone-300 px-5 py-3 text-xs font-semibold uppercase tracking-[0.24em] text-stone-700 transition hover:border-amber-500 hover:text-amber-600" href="/">
|
||||||
|
Continue browsing
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.1001
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.ma-al.com/goc_marek/ps_shop/internal/viewmodel"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CartPage(data viewmodel.CartPageData, cssPath string, jsPath string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<main class=\"min-h-screen bg-[#fdfbf7]\"><div class=\"site-container flex flex-col gap-8 py-6 sm:py-8 lg:py-10\"><header class=\"border-b border-stone-200 pb-6\"><div class=\"flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between\"><div class=\"max-w-3xl\"><p class=\"text-[0.72rem] font-semibold uppercase tracking-[0.26em] text-stone-400\">Cart overview</p><h1 class=\"mt-4 text-3xl font-medium text-stone-800 sm:text-[2.5rem]\">Everything ready for checkout.</h1><p class=\"mt-4 max-w-2xl text-sm leading-7 text-stone-500\">Review quantities, adjust line items, and confirm the final order state before checkout continues in PrestaShop.</p></div><div class=\"grid gap-3 sm:grid-cols-2 lg:min-w-[20rem]\"><div class=\"border border-stone-200 bg-white px-5 py-4 shadow-[0_12px_30px_rgba(20,33,61,0.05)]\"><p class=\"text-[0.68rem] uppercase tracking-[0.28em] text-stone-500\">Line items</p><p class=\"mt-2 text-3xl font-semibold text-stone-900\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.Cart.Items)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 23, Col: 103}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</p></div><div class=\"border border-stone-200 bg-[#fff7e7] px-5 py-4 shadow-[0_12px_30px_rgba(20,33,61,0.05)]\"><p class=\"text-[0.68rem] uppercase tracking-[0.28em] text-stone-500\">Units total</p><p class=\"mt-2 text-3xl font-semibold text-stone-900\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Cart.TotalItems))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 27, Col: 103}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p></div></div></div><div class=\"mt-6 flex flex-wrap items-center gap-3 text-sm text-stone-500\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.Customer != nil {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"border border-stone-200 bg-white px-4 py-2\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 33, Col: 135}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"border border-stone-200 bg-white px-4 py-2\">Guest session</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"border border-stone-200 bg-white px-4 py-2\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("Subtotal " + moneyWithCurrency(data.Cart.SubtotalTaxIncl, cartCurrencySign(data), cartCurrencyCode(data)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 37, Col: 171}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></div></header><section class=\"grid gap-8 xl:grid-cols-[minmax(0,1.55fr)_24rem]\"><div class=\"border border-stone-200 bg-white p-5 shadow-[0_18px_42px_rgba(20,33,61,0.06)] lg:p-6\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(data.Cart.Items) == 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"border border-dashed border-stone-300 bg-[#fcfbf8] p-12 text-center\"><p class=\"text-sm uppercase tracking-[0.28em] text-stone-400\">Empty</p><p class=\"mt-4 text-3xl font-semibold text-stone-900\">Your cart is empty.</p><p class=\"mx-auto mt-3 max-w-md text-sm leading-7 text-stone-500\">Browse categories or return to a product page to add items and build a full order before checkout.</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"mb-4 hidden grid-cols-[minmax(0,1fr)_11rem] gap-4 px-4 text-[0.68rem] uppercase tracking-[0.28em] text-stone-500 md:grid\"><div>Product</div><div class=\"text-right\">Summary</div></div><div class=\"space-y-4\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, item := range data.Cart.Items {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<article class=\"border border-stone-200 bg-[#fffdfa] p-5 transition hover:border-amber-400/60 md:p-6\"><div class=\"flex flex-col gap-5 md:flex-row md:items-start md:justify-between\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if item.ImageURL != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex overflow-hidden border border-stone-200 bg-white md:w-32 md:shrink-0 md:items-center md:justify-center\"><img class=\"block h-32 w-full object-contain\" src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.ImageURL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 60, Col: 78}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" alt=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 60, Col: 96}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"min-w-0 flex-1\"><div class=\"flex flex-wrap items-center gap-2\"><p class=\"text-[0.68rem] uppercase tracking-[0.3em] text-stone-400\">Product</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if cartItemAttributeLabel(item) != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"border border-amber-200 bg-[#fff7e7] px-2.5 py-1 text-[0.65rem] uppercase tracking-[0.16em] text-amber-700\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(cartItemAttributeLabel(item))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 67, Col: 165}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if item.URL != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<a class=\"mt-3 block truncate text-2xl font-semibold text-stone-900 underline-offset-4 transition hover:text-amber-600 hover:underline\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 71, Col: 164}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 71, Col: 178}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<h2 class=\"mt-3 text-2xl font-semibold text-stone-900\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 73, Col: 79}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</h2>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"mt-4 flex flex-wrap gap-2 text-xs text-stone-500\"><span class=\"border border-stone-200 bg-white px-3 py-1.5\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Qty %d", item.Quantity))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 76, Col: 110}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span> <span class=\"border border-stone-200 bg-white px-3 py-1.5\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs("Net " + moneyWithCurrency(item.UnitPrice, item.CurrencySign, item.CurrencyCode))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 77, Col: 154}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span> <span class=\"border border-stone-200 bg-white px-3 py-1.5\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(taxLabel(item.TaxRate))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 78, Col: 96}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span></div></div><div class=\"border border-stone-200 bg-white px-4 py-4 text-left md:min-w-[11rem] md:text-right\"><p class=\"text-[0.68rem] uppercase tracking-[0.24em] text-stone-500\">Line total</p><p class=\"mt-2 text-2xl font-semibold text-stone-900\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(moneyWithCurrency(item.LineTotalTaxIncl, item.CurrencySign, item.CurrencyCode))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 83, Col: 146}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p><p class=\"mt-1 text-xs uppercase tracking-[0.2em] text-stone-500\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(conversionRateLabel(item.ConversionRate, item.CurrencyCode))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 84, Col: 139}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</p></div></div><div class=\"mt-6 flex flex-col gap-3 border-t border-stone-200 pt-5 md:flex-row md:items-center md:justify-between\"><form class=\"flex flex-wrap items-center gap-3\" method=\"post\" action=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(localizedCartPath(data.Locale))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 88, Col: 112}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"><input type=\"hidden\" name=\"action\" value=\"update\"> <input type=\"hidden\" name=\"id_product\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 string
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", item.ProductID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 90, Col: 92}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"> <input type=\"hidden\" name=\"id_product_attribute\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", item.ProductAttributeID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 91, Col: 111}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"> <input type=\"hidden\" name=\"id_customization\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", item.CustomizationID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 92, Col: 104}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"> <label class=\"text-[0.75rem] uppercase tracking-[0.24em] text-stone-400\" for=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("qty-%d-%d", item.ProductID, item.ProductAttributeID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 93, Col: 156}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">Quantity</label><div class=\"flex items-center border border-stone-300 bg-white\"><input class=\"w-20 bg-transparent px-4 py-2.5 text-center text-sm text-stone-900 outline-none ring-0\" id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("qty-%d-%d", item.ProductID, item.ProductAttributeID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 95, Col: 185}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" type=\"number\" min=\"1\" name=\"qty\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 string
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", item.Quantity))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 95, Col: 261}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"></div><button class=\"border border-amber-500 bg-amber-500 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.22em] text-white transition hover:bg-amber-600\" type=\"submit\">Update line</button></form><form method=\"post\" action=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(localizedCartPath(data.Locale))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 101, Col: 70}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\"><input type=\"hidden\" name=\"action\" value=\"delete\"> <input type=\"hidden\" name=\"id_product\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", item.ProductID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 103, Col: 92}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"> <input type=\"hidden\" name=\"id_product_attribute\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var27 string
|
||||||
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", item.ProductAttributeID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 104, Col: 111}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"> <input type=\"hidden\" name=\"id_customization\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var28 string
|
||||||
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", item.CustomizationID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 105, Col: 104}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\"> <button class=\"border border-stone-300 bg-white px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.22em] text-stone-700 transition hover:border-rose-300 hover:bg-rose-50 hover:text-rose-700\" type=\"submit\">Remove</button></form></div></article>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div><aside class=\"xl:sticky xl:top-28 xl:self-start\"><div class=\"border border-stone-200 bg-white p-8 shadow-[0_18px_42px_rgba(20,33,61,0.06)]\"><p class=\"text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-stone-400\">Summary</p><p class=\"mt-3 text-sm leading-7 text-stone-500\">A compact snapshot of the current order before checkout moves to the native PrestaShop flow.</p><div class=\"mt-8 space-y-3\"><div class=\"flex items-center justify-between border border-stone-200 bg-[#fcfbf8] px-4 py-3 text-sm text-stone-700\"><span>Total items</span> <span class=\"font-semibold text-stone-900\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var29 string
|
||||||
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Cart.TotalItems))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 124, Col: 93}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span></div><div class=\"flex items-center justify-between border border-stone-200 bg-[#fcfbf8] px-4 py-3 text-sm text-stone-700\"><span>Unique lines</span> <span class=\"font-semibold text-stone-900\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var30 string
|
||||||
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.Cart.Items)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 128, Col: 93}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</span></div><div class=\"flex items-center justify-between border border-stone-200 bg-[#fcfbf8] px-4 py-3 text-sm text-stone-700\"><span>Subtotal net</span> <span class=\"font-semibold text-stone-900\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var31 string
|
||||||
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(moneyWithCurrency(data.Cart.Subtotal, cartCurrencySign(data), cartCurrencyCode(data)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 132, Col: 139}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span></div><div class=\"flex items-center justify-between border border-amber-200 bg-[#fff7e7] px-5 py-4 text-lg font-semibold text-stone-900\"><span>Subtotal gross</span> <span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var32 string
|
||||||
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(moneyWithCurrency(data.Cart.SubtotalTaxIncl, cartCurrencySign(data), cartCurrencyCode(data)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 136, Col: 109}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span></div></div><a class=\"mt-8 inline-flex w-full items-center justify-center bg-amber-500 px-5 py-3.5 text-sm font-semibold uppercase tracking-[0.22em] text-white transition hover:bg-amber-600\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var33 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(data.ShopBaseURL + "/order")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 139, Col: 220}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">Checkout in PrestaShop</a> <a class=\"mt-3 inline-flex w-full items-center justify-center border border-stone-300 px-5 py-3 text-xs font-semibold uppercase tracking-[0.24em] text-stone-700 transition hover:border-amber-500 hover:text-amber-600\" href=\"/\">Continue browsing</a></div></aside></section></div></main>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = Layout("Cart", cssPath, jsPath, data.Menu, data.Locale, data.Cart.TotalItems).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
+97
-42
@@ -7,63 +7,118 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
templ CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath string) {
|
templ CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath string) {
|
||||||
@Layout(data.Category.Name, cssPath, jsPath, data.Menu, data.Locale) {
|
@Layout(data.Category.Name, cssPath, jsPath, data.Menu, data.Locale, layoutCartItems(data.CartSummary)) {
|
||||||
<main class="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.18),_transparent_35%),linear-gradient(180deg,#0b1020,#111827)]">
|
<main class="min-h-screen bg-[#fdfbf7]">
|
||||||
<div class="mx-auto flex max-w-7xl flex-col gap-10 px-6 py-10 lg:px-8">
|
<div class="site-container flex flex-col gap-8 py-6 sm:py-8 lg:py-10">
|
||||||
<header class="rounded-[2rem] border border-emerald-500/20 bg-white/5 p-8 backdrop-blur">
|
<nav class="rounded-sm bg-[#eceae7] px-4 py-3 text-[0.82rem] text-stone-500 sm:px-5">
|
||||||
<p class="text-xs uppercase tracking-[0.32em] text-emerald-300">Category</p>
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<h1 class="mt-4 font-serif text-4xl text-white">{ data.Category.Name }</h1>
|
<a class="transition hover:text-amber-600" href={ data.ShopBaseURL }>9b-plus</a>
|
||||||
<div class="mt-4 flex items-center justify-between gap-6 text-sm text-slate-300">
|
<span>/</span>
|
||||||
<p>{ fmt.Sprintf("Products loaded: %d", len(data.Category.Products)) }</p>
|
<span>Category</span>
|
||||||
if data.Customer != nil {
|
<span>/</span>
|
||||||
<p>{ fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName) }</p>
|
<span class="text-stone-700">{ data.Category.Name }</span>
|
||||||
} else {
|
|
||||||
<p>Guest session</p>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="border-b border-stone-200 pb-6">
|
||||||
|
<div class="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="max-w-3xl">
|
||||||
|
<p class="text-[0.72rem] font-semibold uppercase tracking-[0.26em] text-stone-400">Category</p>
|
||||||
|
<h1 class="mt-4 text-3xl font-medium uppercase tracking-[0.06em] text-stone-800 sm:text-[2.1rem]">{ data.Category.Name }</h1>
|
||||||
if data.Category.Description != "" {
|
if data.Category.Description != "" {
|
||||||
<div class="prose prose-invert mt-6 max-w-none text-slate-300">
|
<div class="category-description mt-5 max-w-2xl text-sm leading-7 text-stone-500">
|
||||||
@templ.Raw(data.Category.Description)
|
@templ.Raw(data.Category.Description)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
if data.Customer != nil {
|
||||||
|
<div class="text-sm text-stone-500 lg:pt-8">
|
||||||
|
{ fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName) }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
<section class="flex flex-col gap-4 border-y border-stone-200 py-4">
|
||||||
for _, product := range data.Category.Products {
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<article class="group rounded-[1.75rem] border border-white/10 bg-slate-900/70 p-6 shadow-[0_24px_80px_rgba(0,0,0,0.35)] transition hover:-translate-y-1 hover:border-emerald-400/40">
|
<div class="flex items-center gap-4">
|
||||||
<p class="text-xs uppercase tracking-[0.28em] text-emerald-300">Product</p>
|
<button class="inline-flex h-11 w-11 items-center justify-center bg-amber-500 text-xl text-white shadow-[0_8px_18px_rgba(245,158,11,0.28)] transition hover:bg-amber-600" type="button" aria-label="Filters">
|
||||||
<h2 class="mt-3 text-2xl font-semibold text-white">{ product.Name }</h2>
|
≡
|
||||||
<p class="mt-4 text-sm leading-7 text-slate-300">{ product.Description }</p>
|
</button>
|
||||||
<div class="mt-8 flex items-center justify-between gap-4">
|
<p class="text-sm text-stone-500">
|
||||||
<p class="text-2xl font-semibold text-white">{ fmt.Sprintf("%.2f", product.Price) }</p>
|
{ fmt.Sprintf("Showing %d-%d of %d products", categoryPageStart(data.Pagination), categoryPageEnd(data.Pagination, len(data.Category.Products)), data.Pagination.TotalItems) }
|
||||||
<a class="rounded-full border border-emerald-400/40 px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-emerald-200 transition hover:bg-emerald-300 hover:text-slate-950" href={ product.URL }>
|
</p>
|
||||||
View Product
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-4 sm:justify-end">
|
||||||
|
if data.Category.Description != "" {
|
||||||
|
<a class="inline-flex min-h-11 items-center justify-center bg-amber-500 px-6 text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-white shadow-[0_8px_18px_rgba(245,158,11,0.22)] transition hover:bg-amber-600" href="#category-description">
|
||||||
|
More
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<label class="flex min-w-[13rem] flex-col gap-1 text-[0.64rem] font-semibold uppercase tracking-[0.18em] text-stone-400">
|
||||||
|
<span>Sort by</span>
|
||||||
|
<select class="border-0 border-b border-amber-300 bg-transparent px-0 py-1 text-sm font-medium normal-case tracking-normal text-stone-700 focus:border-amber-500 focus:outline-none focus:ring-0">
|
||||||
|
<option>Most popular</option>
|
||||||
|
<option>Price: low to high</option>
|
||||||
|
<option>Price: high to low</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
|
for _, product := range data.Category.Products {
|
||||||
|
<article class="group flex h-full flex-col items-center text-center">
|
||||||
|
<a class="flex h-full w-full flex-col items-center rounded-sm px-3 pb-4 pt-2 transition hover:-translate-y-1" href={ product.URL }>
|
||||||
|
if product.ImageURL != "" {
|
||||||
|
<div class="flex h-[20rem] w-full items-center justify-center overflow-hidden bg-white">
|
||||||
|
<img class="max-h-[15.5rem] w-auto max-w-[82%] object-contain transition duration-500 group-hover:scale-[1.04]" src={ product.ImageURL } alt={ product.Name }/>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="flex h-[20rem] w-full items-center justify-center bg-stone-100 text-5xl font-semibold text-stone-300">
|
||||||
|
{ productInitial(product.Name) }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<h2 class="mt-6 text-[1.02rem] font-medium leading-6 text-stone-800">{ product.Name }</h2>
|
||||||
|
if product.ShortDescription != "" {
|
||||||
|
<p class="mt-3 max-w-[17rem] text-[0.92rem] leading-6 text-stone-400">{ truncatedPlainTextHTML(product.ShortDescription, 90) }</p>
|
||||||
|
} else if product.EAN13 != "" {
|
||||||
|
<p class="mt-3 text-[0.92rem] leading-6 text-stone-400">EAN { product.EAN13 }</p>
|
||||||
|
} else {
|
||||||
|
<p class="mt-3 text-[0.92rem] leading-6 text-stone-400">{ taxLabel(product.TaxRate) }</p>
|
||||||
|
}
|
||||||
|
<div class="mt-5 flex items-baseline gap-2 text-stone-900">
|
||||||
|
<p class="text-[2rem] font-semibold leading-none">{ moneyWithCurrency(product.PriceTaxIncl, product.CurrencySign, product.CurrencyCode) }</p>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-[0.72rem] uppercase tracking-[0.18em] text-stone-400">{ conversionRateLabel(product.ConversionRate, product.CurrencyCode) }</p>
|
||||||
|
</a>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
if data.Session != nil {
|
if data.Category.Description != "" {
|
||||||
<section class="rounded-[2rem] border border-white/10 bg-slate-950/70 p-8">
|
<section class="rounded-sm border border-stone-200 bg-white px-5 py-6 shadow-[0_12px_30px_rgba(20,33,61,0.05)]" id="category-description">
|
||||||
<p class="text-xs uppercase tracking-[0.28em] text-emerald-300">Go Cookie Debug</p>
|
<p class="text-[0.7rem] font-semibold uppercase tracking-[0.22em] text-stone-400">More about this category</p>
|
||||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
<div class="category-description mt-4 max-w-none text-sm leading-7 text-stone-600">
|
||||||
<div>
|
@templ.Raw(data.Category.Description)
|
||||||
<p class="text-sm font-semibold text-white">Raw Cookie</p>
|
|
||||||
<pre class="mt-3 overflow-x-auto rounded-2xl bg-black/30 p-4 text-xs leading-6 text-slate-300">{ data.Session.RawCookie }</pre>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold text-white">Decoded Values</p>
|
|
||||||
<pre class="mt-3 overflow-x-auto rounded-2xl bg-black/30 p-4 text-xs leading-6 text-slate-300">
|
|
||||||
for _, line := range sessionCookieLines(data.Session) {
|
|
||||||
{ line }
|
|
||||||
{"\n"}
|
|
||||||
}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.Pagination.TotalPages > 1 {
|
||||||
|
<nav class="flex flex-col items-center justify-between gap-4 border-t border-stone-200 pt-6 text-sm text-stone-500 md:flex-row">
|
||||||
|
<p>{ fmt.Sprintf("Page %d of %d", data.Pagination.Page, data.Pagination.TotalPages) }</p>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
if data.Pagination.PrevURL != "" {
|
||||||
|
<a class="inline-flex min-h-11 items-center justify-center border border-stone-300 px-5 font-semibold uppercase tracking-[0.18em] text-stone-700 transition hover:border-amber-500 hover:text-amber-600" href={ data.Pagination.PrevURL }>Previous</a>
|
||||||
|
}
|
||||||
|
if data.Pagination.NextURL != "" {
|
||||||
|
<a class="inline-flex min-h-11 items-center justify-center bg-amber-500 px-5 font-semibold uppercase tracking-[0.18em] text-white transition hover:bg-amber-600" href={ data.Pagination.NextURL }>Next</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|||||||
+246
-74
@@ -47,56 +47,59 @@ func CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath string
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<main class=\"min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.18),_transparent_35%),linear-gradient(180deg,#0b1020,#111827)]\"><div class=\"mx-auto flex max-w-7xl flex-col gap-10 px-6 py-10 lg:px-8\"><header class=\"rounded-[2rem] border border-emerald-500/20 bg-white/5 p-8 backdrop-blur\"><p class=\"text-xs uppercase tracking-[0.32em] text-emerald-300\">Category</p><h1 class=\"mt-4 font-serif text-4xl text-white\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<main class=\"min-h-screen bg-[#fdfbf7]\"><div class=\"site-container flex flex-col gap-8 py-6 sm:py-8 lg:py-10\"><nav class=\"rounded-sm bg-[#eceae7] px-4 py-3 text-[0.82rem] text-stone-500 sm:px-5\"><div class=\"flex flex-wrap items-center gap-x-2 gap-y-1\"><a class=\"transition hover:text-amber-600\" href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 templ.SafeURL
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Category.Name)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(data.ShopBaseURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 15, Col: 73}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 15, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h1><div class=\"mt-4 flex items-center justify-between gap-6 text-sm text-slate-300\"><p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">9b-plus</a> <span>/</span> <span>Category</span> <span>/</span> <span class=\"text-stone-700\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Products loaded: %d", len(data.Category.Products)))
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.Category.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 17, Col: 74}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 19, Col: 55}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span></div></nav><header class=\"border-b border-stone-200 pb-6\"><div class=\"flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between\"><div class=\"max-w-3xl\"><p class=\"text-[0.72rem] font-semibold uppercase tracking-[0.26em] text-stone-400\">Category</p><h1 class=\"mt-4 text-3xl font-medium uppercase tracking-[0.06em] text-stone-800 sm:text-[2.1rem]\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if data.Customer != nil {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName))
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.Category.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 19, Col: 81}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 27, Col: 125}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</h1>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
if data.Category.Description != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<p>Guest session</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"category-description mt-5 max-w-2xl text-sm leading-7 text-stone-500\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templ.Raw(data.Category.Description).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -105,12 +108,17 @@ func CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath string
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if data.Category.Description != "" {
|
if data.Customer != nil {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"prose prose-invert mt-6 max-w-none text-slate-300\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"text-sm text-stone-500 lg:pt-8\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templ.Raw(data.Category.Description).Render(ctx, templ_7745c5c3_Buffer)
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 36, Col: 79}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -119,126 +127,290 @@ func CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath string
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</header><section class=\"grid gap-5 md:grid-cols-2 xl:grid-cols-3\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></header><section class=\"flex flex-col gap-4 border-y border-stone-200 py-4\"><div class=\"flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between\"><div class=\"flex items-center gap-4\"><button class=\"inline-flex h-11 w-11 items-center justify-center bg-amber-500 text-xl text-white shadow-[0_8px_18px_rgba(245,158,11,0.28)] transition hover:bg-amber-600\" type=\"button\" aria-label=\"Filters\">≡</button><p class=\"text-sm text-stone-500\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
for _, product := range data.Category.Products {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<article class=\"group rounded-[1.75rem] border border-white/10 bg-slate-900/70 p-6 shadow-[0_24px_80px_rgba(0,0,0,0.35)] transition hover:-translate-y-1 hover:border-emerald-400/40\"><p class=\"text-xs uppercase tracking-[0.28em] text-emerald-300\">Product</p><h2 class=\"mt-3 text-2xl font-semibold text-white\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 string
|
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(product.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 35, Col: 72}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h2><p class=\"mt-4 text-sm leading-7 text-slate-300\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(product.Description)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Showing %d-%d of %d products", categoryPageStart(data.Pagination), categoryPageEnd(data.Pagination, len(data.Category.Products)), data.Pagination.TotalItems))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 36, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 49, Col: 180}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p><div class=\"mt-8 flex items-center justify-between gap-4\"><p class=\"text-2xl font-semibold text-white\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div><div class=\"flex flex-wrap items-center gap-4 sm:justify-end\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
if data.Category.Description != "" {
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", product.Price))
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a class=\"inline-flex min-h-11 items-center justify-center bg-amber-500 px-6 text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-white shadow-[0_8px_18px_rgba(245,158,11,0.22)] transition hover:bg-amber-600\" href=\"#category-description\">More</a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 38, Col: 89}
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<label class=\"flex min-w-[13rem] flex-col gap-1 text-[0.64rem] font-semibold uppercase tracking-[0.18em] text-stone-400\"><span>Sort by</span> <select class=\"border-0 border-b border-amber-300 bg-transparent px-0 py-1 text-sm font-medium normal-case tracking-normal text-stone-700 focus:border-amber-500 focus:outline-none focus:ring-0\"><option>Most popular</option> <option>Price: low to high</option> <option>Price: high to low</option></select></label></div></div></section><section class=\"grid gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, product := range data.Category.Products {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<article class=\"group flex h-full flex-col items-center text-center\"><a class=\"flex h-full w-full flex-col items-center rounded-sm px-3 pb-4 pt-2 transition hover:-translate-y-1\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(product.URL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 73, Col: 135}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p><a class=\"rounded-full border border-emerald-400/40 px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-emerald-200 transition hover:bg-emerald-300 hover:text-slate-950\" href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 templ.SafeURL
|
if product.ImageURL != "" {
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(product.URL)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"flex h-[20rem] w-full items-center justify-center overflow-hidden bg-white\"><img class=\"max-h-[15.5rem] w-auto max-w-[82%] object-contain transition duration-500 group-hover:scale-[1.04]\" src=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 39, Col: 209}
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(product.ImageURL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 76, Col: 144}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">View Product</a></div></article>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" alt=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if data.Session != nil {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section class=\"rounded-[2rem] border border-white/10 bg-slate-950/70 p-8\"><p class=\"text-xs uppercase tracking-[0.28em] text-emerald-300\">Go Cookie Debug</p><div class=\"mt-6 grid gap-6 lg:grid-cols-2\"><div><p class=\"text-sm font-semibold text-white\">Raw Cookie</p><pre class=\"mt-3 overflow-x-auto rounded-2xl bg-black/30 p-4 text-xs leading-6 text-slate-300\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.Session.RawCookie)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(product.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 53, Col: 127}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 76, Col: 165}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</pre></div><div><p class=\"text-sm font-semibold text-white\">Decoded Values</p><pre class=\"mt-3 overflow-x-auto rounded-2xl bg-black/30 p-4 text-xs leading-6 text-slate-300\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, line := range sessionCookieLines(data.Session) {
|
} else {
|
||||||
var templ_7745c5c3_Var11 string
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"flex h-[20rem] w-full items-center justify-center bg-stone-100 text-5xl font-semibold text-stone-300\">")
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(line)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 59, Col: 16}
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(productInitial(product.Name))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 80, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<h2 class=\"mt-6 text-[1.02rem] font-medium leading-6 text-stone-800\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs("\n")
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(product.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 60, Col: 15}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 83, Col: 91}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</h2>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</pre></div></div></section>")
|
if product.ShortDescription != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p class=\"mt-3 max-w-[17rem] text-[0.92rem] leading-6 text-stone-400\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(truncatedPlainTextHTML(product.ShortDescription, 90))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 85, Col: 133}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if product.EAN13 != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<p class=\"mt-3 text-[0.92rem] leading-6 text-stone-400\">EAN ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(product.EAN13)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 87, Col: 84}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"mt-3 text-[0.92rem] leading-6 text-stone-400\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(taxLabel(product.TaxRate))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 89, Col: 92}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div></main>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"mt-5 flex items-baseline gap-2 text-stone-900\"><p class=\"text-[2rem] font-semibold leading-none\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(moneyWithCurrency(product.PriceTaxIncl, product.CurrencySign, product.CurrencyCode))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 92, Col: 144}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p></div><p class=\"mt-3 text-[0.72rem] uppercase tracking-[0.18em] text-stone-400\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(conversionRateLabel(product.ConversionRate, product.CurrencyCode))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 94, Col: 149}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p></a></article>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.Category.Description != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<section class=\"rounded-sm border border-stone-200 bg-white px-5 py-6 shadow-[0_12px_30px_rgba(20,33,61,0.05)]\" id=\"category-description\"><p class=\"text-[0.7rem] font-semibold uppercase tracking-[0.22em] text-stone-400\">More about this category</p><div class=\"category-description mt-4 max-w-none text-sm leading-7 text-stone-600\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templ.Raw(data.Category.Description).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data.Pagination.TotalPages > 1 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<nav class=\"flex flex-col items-center justify-between gap-4 border-t border-stone-200 pt-6 text-sm text-stone-500 md:flex-row\"><p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Page %d of %d", data.Pagination.Page, data.Pagination.TotalPages))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 111, Col: 89}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</p><div class=\"flex items-center gap-3\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.Pagination.PrevURL != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a class=\"inline-flex min-h-11 items-center justify-center border border-stone-300 px-5 font-semibold uppercase tracking-[0.18em] text-stone-700 transition hover:border-amber-500 hover:text-amber-600\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(data.Pagination.PrevURL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 114, Col: 239}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\">Previous</a> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data.Pagination.NextURL != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<a class=\"inline-flex min-h-11 items-center justify-center bg-amber-500 px-5 font-semibold uppercase tracking-[0.18em] text-white transition hover:bg-amber-600\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(data.Pagination.NextURL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 117, Col: 199}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">Next</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></nav>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div></main>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
templ_7745c5c3_Err = Layout(data.Category.Name, cssPath, jsPath, data.Menu, data.Locale).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Layout(data.Category.Name, cssPath, jsPath, data.Menu, data.Locale, layoutCartItems(data.CartSummary)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -2,14 +2,19 @@ package templates
|
|||||||
|
|
||||||
import pscatalog "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/catalog"
|
import pscatalog "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/catalog"
|
||||||
|
|
||||||
templ Layout(title string, cssPath string, jsPath string, menu []pscatalog.MenuItem, locale pscatalog.HeaderLocaleData) {
|
templ Layout(title string, cssPath string, jsPath string, menu []pscatalog.MenuItem, locale pscatalog.HeaderLocaleData, cartItems int64) {
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang={ pageLanguage(locale) }>
|
<html lang={ pageLanguage(locale) }>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>{ title }</title>
|
<title>{ title }</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@splidejs/splide@4.1.4/dist/css/splide.min.css"/>
|
||||||
<link rel="stylesheet" href={ cssPath }/>
|
<link rel="stylesheet" href={ cssPath }/>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@splidejs/splide@4.1.4/dist/js/splide.min.js" defer></script>
|
||||||
<script type="module" src={ jsPath } defer></script>
|
<script type="module" src={ jsPath } defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen text-stone-900 antialiased">
|
<body class="min-h-screen text-stone-900 antialiased">
|
||||||
@@ -21,9 +26,9 @@ templ Layout(title string, cssPath string, jsPath string, menu []pscatalog.MenuI
|
|||||||
<span class="text-base">✉</span>
|
<span class="text-base">✉</span>
|
||||||
<span>info@9b-plus.com</span>
|
<span>info@9b-plus.com</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="inline-flex items-center gap-2 transition hover:text-amber-600" href="tel:+420533312341">
|
<a class="inline-flex items-center gap-2 transition hover:text-amber-600" href="tel:+48221532426">
|
||||||
<span class="text-base">☎</span>
|
<span class="text-base">☎</span>
|
||||||
<span>+420 533 312 341</span>
|
<span>+48 221 532 426</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
if hasHeaderLocale(locale) {
|
if hasHeaderLocale(locale) {
|
||||||
@@ -57,7 +62,12 @@ templ Layout(title string, cssPath string, jsPath string, menu []pscatalog.MenuI
|
|||||||
}
|
}
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a class="nav-icon" href="#" aria-label="Search">⌕</a>
|
<a class="nav-icon" href="#" aria-label="Search">⌕</a>
|
||||||
<a class="nav-icon" href="#" aria-label="Cart">🛒</a>
|
<a class="nav-icon relative" href={ localizedCartPath(locale) } aria-label="Cart">
|
||||||
|
🛒
|
||||||
|
if cartItems > 0 {
|
||||||
|
<span class="absolute -right-2 -top-2 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-amber-300 px-1.5 text-[0.65rem] font-semibold leading-none text-stone-950">{ cartItems }</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+285
-20
@@ -1,26 +1,31 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
pscart "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cart"
|
||||||
pscatalog "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/catalog"
|
pscatalog "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/catalog"
|
||||||
pscookie "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/cookie"
|
"git.ma-al.com/goc_marek/ps_shop/internal/viewmodel"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var htmlTagPattern = regexp.MustCompile(`<[^>]+>`)
|
||||||
|
|
||||||
func menuListClass(depth int) string {
|
func menuListClass(depth int) string {
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
return "flex min-w-0 flex-col gap-2 text-sm"
|
return "flex min-w-0 flex-col gap-2 text-sm"
|
||||||
}
|
}
|
||||||
return "mt-2 space-y-2 border-l border-stone-200 pl-4 text-sm"
|
return "mt-2 space-y-2 border-l border-stone-200/80 pl-4 text-sm"
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuLinkClass(depth int) string {
|
func menuLinkClass(depth int) string {
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
return "inline-flex items-center gap-2 px-2 py-2 text-[1.02rem] font-medium text-stone-900 transition hover:text-amber-600"
|
return "flex items-center justify-between gap-3 rounded-[1.1rem] border border-stone-200/80 bg-stone-50/90 px-4 py-3 text-[1rem] font-medium text-stone-900 shadow-[0_10px_24px_rgba(20,33,61,0.05)] transition hover:border-amber-300/60 hover:bg-white hover:text-amber-700"
|
||||||
}
|
}
|
||||||
return "inline-flex items-center gap-2 text-stone-700 transition hover:text-amber-600"
|
return "inline-flex items-center gap-2 py-1 text-stone-700 transition hover:text-amber-600"
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuItemClass(depth int, hasChildren bool) string {
|
func menuItemClass(depth int, hasChildren bool) string {
|
||||||
@@ -79,20 +84,280 @@ func menuPanelID(id int64) string {
|
|||||||
return "mega-menu-panel-" + strconv.FormatInt(id, 10)
|
return "mega-menu-panel-" + strconv.FormatInt(id, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionCookieLines(session *pscookie.SessionContext) []string {
|
func moneyWithCurrency(amount float64, sign, code string) string {
|
||||||
if session == nil || len(session.Values) == 0 {
|
formatted := fmt.Sprintf("%.2f", amount)
|
||||||
return nil
|
sign = strings.TrimSpace(sign)
|
||||||
|
switch {
|
||||||
|
case sign != "":
|
||||||
|
return formatted + " " + sign
|
||||||
|
default:
|
||||||
|
return formatted
|
||||||
}
|
}
|
||||||
|
}
|
||||||
keys := make([]string, 0, len(session.Values))
|
|
||||||
for key := range session.Values {
|
func taxLabel(rate float64) string {
|
||||||
keys = append(keys, key)
|
return fmt.Sprintf("Tax %.2f%%", rate)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
|
||||||
|
func conversionRateLabel(rate float64, code string) string {
|
||||||
lines := make([]string, 0, len(keys))
|
code = strings.TrimSpace(code)
|
||||||
for _, key := range keys {
|
if code == "" {
|
||||||
lines = append(lines, key+"="+session.Values[key])
|
return fmt.Sprintf("Rate %.6f", rate)
|
||||||
}
|
}
|
||||||
return lines
|
return fmt.Sprintf("Rate %.6f %s", rate, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func localizedCartPath(locale pscatalog.HeaderLocaleData) string {
|
||||||
|
code := strings.ToLower(strings.TrimSpace(locale.CurrentLanguage.Code))
|
||||||
|
if code == "" {
|
||||||
|
return "/cart"
|
||||||
|
}
|
||||||
|
return "/" + code + "/cart"
|
||||||
|
}
|
||||||
|
|
||||||
|
func plainTextHTML(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value = htmlTagPattern.ReplaceAllString(value, " ")
|
||||||
|
value = html.UnescapeString(value)
|
||||||
|
return strings.Join(strings.Fields(value), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncatedPlainTextHTML(value string, maxChars int) string {
|
||||||
|
value = plainTextHTML(value)
|
||||||
|
if value == "" || maxChars <= 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
runes := []rune(value)
|
||||||
|
if len(runes) <= maxChars {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
cut := strings.TrimSpace(string(runes[:maxChars]))
|
||||||
|
if idx := strings.LastIndex(cut, " "); idx >= maxChars/2 {
|
||||||
|
cut = strings.TrimSpace(cut[:idx])
|
||||||
|
}
|
||||||
|
if cut == "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return cut + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func combinationAttributeLabel(publicName, group string) string {
|
||||||
|
publicName = strings.TrimSpace(publicName)
|
||||||
|
if publicName != "" {
|
||||||
|
return publicName
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexColor(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(value, "#") {
|
||||||
|
value = "#" + value
|
||||||
|
}
|
||||||
|
if len(value) != 7 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range value[1:] {
|
||||||
|
if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func cartCurrencyCode(data viewmodel.CartPageData) string {
|
||||||
|
if code := cartCurrencyField(data.Cart.Items, func(item pscart.Item) string { return item.CurrencyCode }); code != "" {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func cartCurrencySign(data viewmodel.CartPageData) string {
|
||||||
|
if sign := cartCurrencyField(data.Cart.Items, func(item pscart.Item) string { return item.CurrencySign }); sign != "" {
|
||||||
|
return sign
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func cartCurrencyField(items []pscart.Item, pick func(pscart.Item) string) string {
|
||||||
|
for _, item := range items {
|
||||||
|
value := strings.TrimSpace(pick(item))
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func cartItemAttributeLabel(item pscart.Item) string {
|
||||||
|
if len(item.Attributes) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, 0, len(item.Attributes))
|
||||||
|
for _, attribute := range item.Attributes {
|
||||||
|
group := strings.TrimSpace(attribute.Group)
|
||||||
|
value := strings.TrimSpace(attribute.Value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if group != "" {
|
||||||
|
parts = append(parts, group+": "+value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, " • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func layoutCartItems(summary *pscart.Summary) int64 {
|
||||||
|
if summary == nil || summary.TotalItems <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return summary.TotalItems
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryPageStart(pagination viewmodel.CategoryPagination) int64 {
|
||||||
|
if pagination.TotalItems <= 0 || pagination.Page <= 0 || pagination.PerPage <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64((pagination.Page-1)*pagination.PerPage) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryPageEnd(pagination viewmodel.CategoryPagination, loaded int) int64 {
|
||||||
|
if pagination.TotalItems <= 0 || loaded <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
end := categoryPageStart(pagination) + int64(loaded) - 1
|
||||||
|
if end > pagination.TotalItems {
|
||||||
|
return pagination.TotalItems
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
|
type productVariantGroupView struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
GroupType string
|
||||||
|
Options []productVariantOptionView
|
||||||
|
}
|
||||||
|
|
||||||
|
type productVariantOptionView struct {
|
||||||
|
Value string
|
||||||
|
ColorStyle string
|
||||||
|
CombinationIDs string
|
||||||
|
Selected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func productVariantGroups(combinations []pscatalog.ProductCombination, defaultID int64) []productVariantGroupView {
|
||||||
|
groups := make([]productVariantGroupView, 0)
|
||||||
|
groupIndex := make(map[string]int)
|
||||||
|
optionIndex := make(map[string]map[string]int)
|
||||||
|
|
||||||
|
for _, combination := range combinations {
|
||||||
|
for _, attribute := range combination.Attributes {
|
||||||
|
label := combinationAttributeLabel(attribute.PublicName, attribute.Group)
|
||||||
|
if label == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(label))
|
||||||
|
idx, exists := groupIndex[key]
|
||||||
|
if !exists {
|
||||||
|
groups = append(groups, productVariantGroupView{
|
||||||
|
Key: "variant-group-" + strconv.Itoa(len(groups)),
|
||||||
|
Label: label,
|
||||||
|
GroupType: normalizedGroupType(attribute.GroupType),
|
||||||
|
Options: make([]productVariantOptionView, 0),
|
||||||
|
})
|
||||||
|
idx = len(groups) - 1
|
||||||
|
groupIndex[key] = idx
|
||||||
|
optionIndex[key] = make(map[string]int)
|
||||||
|
}
|
||||||
|
|
||||||
|
optionKey := strings.ToLower(strings.TrimSpace(attribute.Value)) + "|" + normalizedColorStyle(attribute.Color)
|
||||||
|
optIdx, exists := optionIndex[key][optionKey]
|
||||||
|
if !exists {
|
||||||
|
groups[idx].Options = append(groups[idx].Options, productVariantOptionView{
|
||||||
|
Value: attribute.Value,
|
||||||
|
ColorStyle: normalizedColorStyle(attribute.Color),
|
||||||
|
CombinationIDs: strconv.FormatInt(combination.ID, 10),
|
||||||
|
Selected: combination.ID == defaultID,
|
||||||
|
})
|
||||||
|
optionIndex[key][optionKey] = len(groups[idx].Options) - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
option := &groups[idx].Options[optIdx]
|
||||||
|
option.CombinationIDs += "," + strconv.FormatInt(combination.ID, 10)
|
||||||
|
if combination.ID == defaultID {
|
||||||
|
option.Selected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedGroupType(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
switch value {
|
||||||
|
case "color", "radio", "select":
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return "select"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedColorStyle(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(value, "#") {
|
||||||
|
value = "#" + value
|
||||||
|
}
|
||||||
|
if !isHexColor(value) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func variantColorOptionClass(selected bool) string {
|
||||||
|
if selected {
|
||||||
|
return "inline-flex min-h-10 min-w-10 items-center justify-center border border-stone-900 p-0.5 ring-1 ring-stone-900 transition"
|
||||||
|
}
|
||||||
|
return "inline-flex min-h-10 min-w-10 items-center justify-center border border-stone-300 p-0.5 transition hover:border-stone-700"
|
||||||
|
}
|
||||||
|
|
||||||
|
func variantRadioOptionClass(selected bool) string {
|
||||||
|
if selected {
|
||||||
|
return "rounded-full border border-stone-900 bg-stone-900 px-4 py-2 text-sm font-medium text-stone-50 transition"
|
||||||
|
}
|
||||||
|
return "rounded-full border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-900 transition hover:border-stone-700"
|
||||||
|
}
|
||||||
|
|
||||||
|
func variantSelectOptionClass(selected bool) string {
|
||||||
|
if selected {
|
||||||
|
return "w-full rounded-2xl bg-stone-900 px-4 py-3 text-left text-sm font-medium text-stone-50 transition"
|
||||||
|
}
|
||||||
|
return "w-full rounded-2xl px-4 py-3 text-left text-sm font-medium text-stone-700 transition hover:bg-stone-100 hover:text-stone-950"
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedVariantOptionValue(options []productVariantOptionView) string {
|
||||||
|
for _, option := range options {
|
||||||
|
if option.Selected {
|
||||||
|
return option.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(options) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return options[0].Value
|
||||||
}
|
}
|
||||||
|
|||||||
+379
-343
File diff suppressed because it is too large
Load Diff
+271
-38
@@ -7,12 +7,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
templ ProductPage(data viewmodel.ProductPageData, cssPath string, jsPath string) {
|
templ ProductPage(data viewmodel.ProductPageData, cssPath string, jsPath string) {
|
||||||
@Layout(data.Product.Name, cssPath, jsPath, data.Menu, data.Locale) {
|
@Layout(data.Product.Name, cssPath, jsPath, data.Menu, data.Locale, layoutCartItems(data.CartSummary)) {
|
||||||
<main class="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.28),_transparent_40%),linear-gradient(180deg,#0c0a09,#1c1917)]">
|
<main class="min-h-screen bg-[#fdfbf7]">
|
||||||
<div class="mx-auto flex max-w-6xl flex-col gap-12 px-6 py-10 lg:px-8">
|
<div class="site-container flex flex-col gap-8 py-6 sm:py-8 lg:py-10">
|
||||||
<header class="flex items-center justify-between border-b border-stone-800 pb-6">
|
<nav class="rounded-sm bg-[#eceae7] px-4 py-3 text-[0.82rem] text-stone-500 sm:px-5">
|
||||||
<a class="text-sm uppercase tracking-[0.32em] text-amber-300" href={ data.ShopBaseURL }>Prestashop Proxy</a>
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<div class="text-right text-sm text-stone-400">
|
<a class="transition hover:text-amber-600" href={ data.ShopBaseURL }>9b-plus</a>
|
||||||
|
if data.CategoryURL != "" && data.Product.CategoryName != "" {
|
||||||
|
<span>/</span>
|
||||||
|
<a class="transition hover:text-amber-600" href={ data.CategoryURL }>{ data.Product.CategoryName }</a>
|
||||||
|
}
|
||||||
|
<span>/</span>
|
||||||
|
<span class="text-stone-700">{ data.Product.Name }</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="flex flex-col gap-4 border-b border-stone-200 pb-6 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.72rem] font-semibold uppercase tracking-[0.26em] text-stone-400">Product</p>
|
||||||
|
<h1 class="mt-4 text-3xl font-medium text-stone-800 sm:text-[2.6rem]">{ data.Product.Name }</h1>
|
||||||
|
if data.Product.CategoryName != "" {
|
||||||
|
<p class="mt-3 text-sm text-stone-500">{ data.Product.CategoryName }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-stone-500 lg:text-right">
|
||||||
if data.Customer != nil {
|
if data.Customer != nil {
|
||||||
<p>{ fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName) }</p>
|
<p>{ fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName) }</p>
|
||||||
} else {
|
} else {
|
||||||
@@ -24,55 +42,270 @@ templ ProductPage(data viewmodel.ProductPageData, cssPath string, jsPath string)
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
<section class="grid gap-8 xl:grid-cols-[minmax(0,1.2fr)_25rem]">
|
||||||
<div class="rounded-3xl border border-stone-800 bg-stone-900/70 p-8 shadow-2xl shadow-amber-950/20">
|
<div class="rounded-sm border border-stone-200 bg-white p-4 shadow-[0_18px_42px_rgba(20,33,61,0.06)] sm:p-6 lg:p-8">
|
||||||
<p class="text-xs uppercase tracking-[0.28em] text-stone-500">Product</p>
|
if len(data.Product.GalleryImages) > 1 {
|
||||||
if data.CategoryURL != "" && data.Product.CategoryName != "" {
|
<div class="splide" aria-label="Product gallery" data-product-gallery-main>
|
||||||
<a class="mt-4 inline-flex text-sm uppercase tracking-[0.24em] text-amber-300 underline underline-offset-4" href={ data.CategoryURL }>{ data.Product.CategoryName }</a>
|
<div class="splide__track">
|
||||||
|
<ul class="splide__list">
|
||||||
|
for i, image := range data.Product.GalleryImages {
|
||||||
|
if image.URL != "" {
|
||||||
|
<li class="splide__slide">
|
||||||
|
<button class="flex min-h-[16rem] w-full items-center justify-center overflow-hidden bg-white text-left sm:min-h-[22rem] lg:min-h-[30rem]" type="button" data-gallery-open="" data-gallery-index={ fmt.Sprintf("%d", i) }>
|
||||||
|
<img class="max-h-[20rem] w-auto max-w-full object-contain sm:max-h-[28rem] lg:max-h-[34rem]" src={ image.URL } alt={ data.Product.Name } data-product-gallery-image="" data-image-url={ image.URL } data-default-image={ data.Product.ImageURL }/>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
}
|
}
|
||||||
<h1 class="mt-4 font-serif text-4xl text-stone-50">{ data.Product.Name }</h1>
|
}
|
||||||
<p class="mt-6 max-w-2xl text-lg leading-8 text-stone-300">{ data.Product.ShortDescription }</p>
|
</ul>
|
||||||
<div class="prose prose-invert mt-8 max-w-none text-stone-300">
|
|
||||||
@templ.Raw(data.Product.Description)
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-6 flex items-center gap-3">
|
||||||
|
<button class="hidden h-11 w-11 shrink-0 items-center justify-center border border-stone-300 bg-white text-lg text-stone-700 transition hover:border-amber-500 hover:text-amber-600 sm:inline-flex" type="button" aria-label="Previous thumbnails" data-product-thumb-prev>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="splide" aria-label="Product gallery thumbnails" data-product-gallery-thumbs>
|
||||||
|
<div class="splide__track">
|
||||||
|
<ul class="splide__list">
|
||||||
|
for _, image := range data.Product.GalleryImages {
|
||||||
|
if image.ThumbURL != "" && image.URL != "" {
|
||||||
|
<li class="splide__slide border border-stone-200 bg-white">
|
||||||
|
<img class="block h-16 w-16 object-cover sm:h-20 sm:w-20 lg:h-24 lg:w-24" src={ image.ThumbURL } alt={ data.Product.Name }/>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="hidden h-11 w-11 shrink-0 items-center justify-center border border-stone-300 bg-white text-lg text-stone-700 transition hover:border-amber-500 hover:text-amber-600 sm:inline-flex" type="button" aria-label="Next thumbnails" data-product-thumb-next>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} else if data.Product.ImageURL != "" {
|
||||||
|
<button class="flex min-h-[16rem] w-full items-center justify-center overflow-hidden bg-white text-left sm:min-h-[22rem] lg:min-h-[30rem]" type="button" data-gallery-open data-gallery-index="0">
|
||||||
|
<img class="max-h-[20rem] w-auto max-w-full object-contain sm:max-h-[28rem] lg:max-h-[34rem]" src={ data.Product.ImageURL } alt={ data.Product.Name } data-product-main-image="" data-default-image={ data.Product.ImageURL }/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<aside class="flex flex-col justify-between rounded-3xl border border-amber-500/30 bg-amber-400/10 p-8">
|
<aside class="rounded-sm border border-stone-200 bg-white p-6 shadow-[0_18px_42px_rgba(20,33,61,0.06)] sm:p-8 xl:sticky xl:top-28 xl:self-start">
|
||||||
<div>
|
<div class="border-b border-stone-200 pb-5">
|
||||||
<p class="text-xs uppercase tracking-[0.28em] text-amber-200">Price</p>
|
<p class="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-stone-400">Price</p>
|
||||||
<p class="mt-4 text-5xl font-semibold text-stone-50">{ fmt.Sprintf("%.2f", data.Product.Price) }</p>
|
<p class="mt-4 text-4xl font-semibold text-stone-900" data-product-price-gross="" data-default-price-gross={ moneyWithCurrency(data.Product.PriceTaxIncl, data.Product.CurrencySign, data.Product.CurrencyCode) }>{ moneyWithCurrency(data.Product.PriceTaxIncl, data.Product.CurrencySign, data.Product.CurrencyCode) }</p>
|
||||||
<p class="mt-2 text-sm text-stone-300">Live data loaded from PrestaShop storage, rendered by Go.</p>
|
<p class="mt-2 text-sm text-stone-500">Including VAT { fmt.Sprintf("%.0f%%", data.Product.TaxRate) }</p>
|
||||||
|
<p class="mt-2 text-sm text-stone-500" data-product-price-net="" data-default-price-net={ "Net " + moneyWithCurrency(data.Product.Price, data.Product.CurrencySign, data.Product.CurrencyCode) }>Net { moneyWithCurrency(data.Product.Price, data.Product.CurrencySign, data.Product.CurrencyCode) }</p>
|
||||||
|
<p class="mt-2 text-sm text-stone-500">{ conversionRateLabel(data.Product.ConversionRate, data.Product.CurrencyCode) }</p>
|
||||||
</div>
|
</div>
|
||||||
<form class="mt-10 flex flex-col gap-4" method="post" action={ data.ShopBaseURL + "/cart" }>
|
<form class="mt-6 flex flex-col gap-4" method="post" action={ localizedCartPath(data.Locale) }>
|
||||||
|
<input type="hidden" name="action" value="add"/>
|
||||||
<input type="hidden" name="id_product" value={ fmt.Sprintf("%d", data.Product.ID) }/>
|
<input type="hidden" name="id_product" value={ fmt.Sprintf("%d", data.Product.ID) }/>
|
||||||
<button class="rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold uppercase tracking-[0.2em] text-stone-950 transition hover:bg-amber-200" type="submit">
|
if len(data.Product.Combinations) > 0 {
|
||||||
Add to cart in PrestaShop
|
<input type="hidden" name="id_product_attribute" value={ fmt.Sprintf("%d", data.Product.DefaultAttribute) } data-variant-combination/>
|
||||||
|
<div class="rounded-sm border border-stone-200 bg-[#fcfbf8] p-5 text-stone-950">
|
||||||
|
<div class="hidden" aria-hidden="true">
|
||||||
|
for _, combination := range data.Product.Combinations {
|
||||||
|
<span
|
||||||
|
data-variant-combination-image={ fmt.Sprintf("%d", combination.ID) }
|
||||||
|
data-image-large={ combination.ImageURL }
|
||||||
|
data-price-gross={ moneyWithCurrency(combination.PriceTaxIncl, data.Product.CurrencySign, data.Product.CurrencyCode) }
|
||||||
|
data-price-net={ "Net " + moneyWithCurrency(combination.Price, data.Product.CurrencySign, data.Product.CurrencyCode) }></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="border border-stone-200 bg-white px-4 py-3">
|
||||||
|
<p class="text-[0.72rem] font-medium uppercase tracking-[0.18em] text-stone-500">Current selection</p>
|
||||||
|
<p class="mt-2 text-base font-medium text-stone-900" data-variant-selection-summary>Choose product options</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 space-y-5" data-variant-picker>
|
||||||
|
for _, group := range productVariantGroups(data.Product.Combinations, data.Product.DefaultAttribute) {
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<p class="text-sm font-medium uppercase tracking-[0.16em] text-stone-700">{ group.Label }</p>
|
||||||
|
<p class="text-sm text-stone-500" data-variant-current={ group.Key }></p>
|
||||||
|
</div>
|
||||||
|
if group.GroupType == "select" {
|
||||||
|
<div class="relative" data-variant-group={ group.Key } data-variant-select="">
|
||||||
|
<button class="flex w-full items-center justify-between gap-4 border-b border-stone-300 px-0 py-2 text-left text-lg text-stone-900 transition hover:text-stone-700" type="button" data-variant-select-trigger="" aria-expanded="false">
|
||||||
|
<span data-variant-select-value="">{ selectedVariantOptionValue(group.Options) }</span>
|
||||||
|
<span class="text-base text-stone-700">▼</span>
|
||||||
</button>
|
</button>
|
||||||
<a class="text-sm text-stone-300 underline underline-offset-4" href={ data.ShopBaseURL + "/login" }>Account and login remain on PrestaShop</a>
|
<div class="absolute left-0 right-0 top-full z-20 mt-3 hidden border border-stone-200 bg-white p-2 shadow-[0_24px_60px_rgba(0,0,0,0.12)]" data-variant-select-menu="">
|
||||||
|
for _, option := range group.Options {
|
||||||
|
<button class={ variantSelectOptionClass(option.Selected) } type="button" data-variant-option="" data-variant-presentation="select" data-combination-ids={ option.CombinationIDs } data-selected={ fmt.Sprintf("%t", option.Selected) }>
|
||||||
|
{ option.Value }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else if group.GroupType == "color" {
|
||||||
|
<div class="mt-1 flex flex-wrap gap-1.5" data-variant-group={ group.Key }>
|
||||||
|
for _, option := range group.Options {
|
||||||
|
<button class={ variantColorOptionClass(option.Selected) } type="button" data-variant-option="" data-variant-presentation="color" data-combination-ids={ option.CombinationIDs } aria-label={ option.Value } title={ option.Value } data-selected={ fmt.Sprintf("%t", option.Selected) }>
|
||||||
|
if option.ColorStyle != "" {
|
||||||
|
<span class="block h-6 w-8 border border-stone-300" style={ "background-color:" + option.ColorStyle }></span>
|
||||||
|
} else {
|
||||||
|
<span class="px-2 text-xs font-medium text-stone-900">{ option.Value }</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="mt-1 flex flex-wrap gap-2" data-variant-group={ group.Key }>
|
||||||
|
for _, option := range group.Options {
|
||||||
|
<button class={ variantRadioOptionClass(option.Selected) } type="button" data-variant-option="" data-variant-presentation="radio" data-combination-ids={ option.CombinationIDs } data-selected={ fmt.Sprintf("%t", option.Selected) }>
|
||||||
|
{ option.Value }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="mt-5 text-sm leading-7 text-stone-600">Selected combination will be added directly to the PrestaShop cart.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<input type="hidden" name="qty" value="1"/>
|
||||||
|
<button class="min-h-12 bg-amber-500 px-5 py-3 text-sm font-semibold uppercase tracking-[0.2em] text-white transition hover:bg-amber-600" type="submit">
|
||||||
|
Add to cart
|
||||||
|
</button>
|
||||||
|
<a class="text-sm text-stone-500 underline underline-offset-4" href={ data.ShopBaseURL + "/login" }>Account and login remain on PrestaShop</a>
|
||||||
</form>
|
</form>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
if data.Session != nil {
|
if data.Product.ShortDescription != "" {
|
||||||
<section class="rounded-3xl border border-stone-800 bg-stone-950/80 p-8">
|
<section class="rounded-sm border border-stone-200 bg-white p-6 shadow-[0_12px_30px_rgba(20,33,61,0.05)] sm:p-8">
|
||||||
<p class="text-xs uppercase tracking-[0.28em] text-stone-500">Go Cookie Debug</p>
|
<div class="flex flex-col gap-2 border-b border-stone-200 pb-5">
|
||||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
<p class="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-stone-400">Summary</p>
|
||||||
<div>
|
<h2 class="text-2xl font-medium text-stone-800">At a glance</h2>
|
||||||
<p class="text-sm font-semibold text-stone-200">Raw Cookie</p>
|
|
||||||
<pre class="mt-3 overflow-x-auto rounded-2xl bg-black/30 p-4 text-xs leading-6 text-stone-300">{ data.Session.RawCookie }</pre>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<p class="mt-6 max-w-none text-lg leading-8 text-stone-600">{ plainTextHTML(data.Product.ShortDescription) }</p>
|
||||||
<p class="text-sm font-semibold text-stone-200">Decoded Values</p>
|
</section>
|
||||||
<pre class="mt-3 overflow-x-auto rounded-2xl bg-black/30 p-4 text-xs leading-6 text-stone-300">
|
|
||||||
for _, line := range sessionCookieLines(data.Session) {
|
|
||||||
{ line }
|
|
||||||
{"\n"}
|
|
||||||
}
|
}
|
||||||
</pre>
|
|
||||||
|
if data.Product.Description != "" {
|
||||||
|
<section class="rounded-sm border border-stone-200 bg-white p-6 shadow-[0_12px_30px_rgba(20,33,61,0.05)] sm:p-8">
|
||||||
|
<div class="flex flex-col gap-2 border-b border-stone-200 pb-5">
|
||||||
|
<p class="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-stone-400">Description</p>
|
||||||
|
<h2 class="text-2xl font-medium text-stone-800">About this product</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="category-description mt-6 max-w-none text-sm leading-7 text-stone-600">
|
||||||
|
@templ.Raw(data.Product.Description)
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(data.Product.Features) > 0 {
|
||||||
|
<section class="rounded-sm border border-stone-200 bg-white p-6 shadow-[0_12px_30px_rgba(20,33,61,0.05)] sm:p-8">
|
||||||
|
<div class="flex flex-col gap-2 border-b border-stone-200 pb-5">
|
||||||
|
<p class="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-stone-400">Features</p>
|
||||||
|
<h2 class="text-2xl font-medium text-stone-800">Product details</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 grid gap-3 md:grid-cols-2">
|
||||||
|
for _, feature := range data.Product.Features {
|
||||||
|
<div class="border border-stone-200 bg-[#fcfbf8] px-5 py-4">
|
||||||
|
<p class="text-[0.7rem] uppercase tracking-[0.24em] text-stone-500">{ feature.Name }</p>
|
||||||
|
<p class="mt-2 text-sm leading-7 text-stone-700">{ plainTextHTML(feature.Value) }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
if len(data.Product.Accessories) > 0 {
|
||||||
|
<section class="rounded-sm border border-stone-200 bg-white p-6 shadow-[0_12px_30px_rgba(20,33,61,0.05)] sm:p-8">
|
||||||
|
<div class="flex flex-col gap-2 border-b border-stone-200 pb-5">
|
||||||
|
<p class="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-stone-400">Related products</p>
|
||||||
|
<h2 class="text-2xl font-medium text-stone-800">Accessories</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 grid gap-x-6 gap-y-10 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
for _, product := range data.Product.Accessories {
|
||||||
|
<article class="group flex h-full flex-col items-center text-center">
|
||||||
|
<a class="flex h-full w-full flex-col items-center px-3 pb-4 pt-2 transition hover:-translate-y-1" href={ product.URL }>
|
||||||
|
if product.ImageURL != "" {
|
||||||
|
<div class="flex h-[16rem] w-full items-center justify-center overflow-hidden bg-white">
|
||||||
|
<img class="max-h-[12rem] w-auto max-w-[82%] object-contain transition duration-500 group-hover:scale-[1.04]" src={ product.ImageURL } alt={ product.Name }/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<h3 class="mt-5 text-[1.02rem] font-medium leading-6 text-stone-800">{ product.Name }</h3>
|
||||||
|
<p class="mt-3 max-w-[17rem] text-[0.92rem] leading-6 text-stone-400">{ truncatedPlainTextHTML(product.ShortDescription, 90) }</p>
|
||||||
|
<p class="mt-5 text-[1.5rem] font-semibold leading-none text-stone-900">{ moneyWithCurrency(product.PriceTaxIncl, product.CurrencySign, product.CurrencyCode) }</p>
|
||||||
|
<p class="mt-3 text-[0.72rem] uppercase tracking-[0.18em] text-stone-400">{ conversionRateLabel(product.ConversionRate, product.CurrencyCode) }</p>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
if len(data.Product.GalleryImages) > 0 {
|
||||||
|
<div class="fixed inset-0 z-[70] hidden bg-black/55 px-3 py-3 backdrop-blur-sm sm:px-4 sm:py-6" aria-hidden="true" data-gallery-modal>
|
||||||
|
<div class="mx-auto flex h-full max-h-full w-full max-w-[104rem] flex-col overflow-hidden border border-stone-200 bg-white p-3 shadow-[0_32px_120px_rgba(0,0,0,0.28)] sm:p-4 md:p-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-stone-200 pb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-stone-400">Gallery</p>
|
||||||
|
<p class="mt-1 line-clamp-2 text-base font-semibold text-stone-900 sm:text-lg">{ data.Product.Name }</p>
|
||||||
|
</div>
|
||||||
|
<button class="border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-stone-700 transition hover:border-amber-500 hover:text-amber-600" type="button" data-gallery-close>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex min-h-0 flex-1 flex-col gap-4 lg:mt-5">
|
||||||
|
<div class="relative flex min-h-[16rem] flex-1 items-center justify-center overflow-hidden border border-stone-200 bg-[#fcfbf8] sm:min-h-[22rem]">
|
||||||
|
if len(data.Product.GalleryImages) > 1 {
|
||||||
|
<div class="splide w-full" aria-label="Expanded product gallery" data-gallery-main-splide>
|
||||||
|
<div class="splide__track">
|
||||||
|
<ul class="splide__list">
|
||||||
|
for _, image := range data.Product.GalleryImages {
|
||||||
|
if image.URL != "" {
|
||||||
|
<li class="splide__slide">
|
||||||
|
<div class="flex min-h-[16rem] w-full items-center justify-center sm:min-h-[22rem]">
|
||||||
|
<img class="max-h-[65vh] w-auto max-w-full object-contain" src={ image.URL } alt={ data.Product.Name } data-gallery-image="" data-image-url={ image.URL }/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="absolute left-2 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center border border-stone-300 bg-white text-lg text-stone-700 transition hover:border-amber-500 hover:text-amber-600 sm:left-4 sm:h-11 sm:w-11" type="button" aria-label="Previous image" data-gallery-prev>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button class="absolute right-2 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center border border-stone-300 bg-white text-lg text-stone-700 transition hover:border-amber-500 hover:text-amber-600 sm:right-4 sm:h-11 sm:w-11" type="button" aria-label="Next image" data-gallery-next>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<img class="max-h-[65vh] w-auto max-w-full object-contain" src={ data.Product.GalleryImages[0].URL } alt={ data.Product.Name } data-gallery-main/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if len(data.Product.GalleryImages) > 1 {
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button class="hidden h-11 w-11 shrink-0 items-center justify-center border border-stone-300 bg-white text-lg text-stone-700 transition hover:border-amber-500 hover:text-amber-600 sm:inline-flex" type="button" aria-label="Previous gallery thumbnails" data-gallery-thumb-prev>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="splide" aria-label="Expanded gallery thumbnails" data-gallery-thumb-splide>
|
||||||
|
<div class="splide__track">
|
||||||
|
<ul class="splide__list">
|
||||||
|
for _, image := range data.Product.GalleryImages {
|
||||||
|
if image.ThumbURL != "" && image.URL != "" {
|
||||||
|
<li class="splide__slide border border-stone-200 bg-white">
|
||||||
|
<img class="block h-16 w-16 object-cover sm:h-20 sm:w-20 lg:h-24 lg:w-24" src={ image.ThumbURL } alt={ data.Product.Name }/>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="hidden h-11 w-11 shrink-0 items-center justify-center border border-stone-300 bg-white text-lg text-stone-700 transition hover:border-amber-500 hover:text-amber-600 sm:inline-flex" type="button" aria-label="Next gallery thumbnails" data-gallery-thumb-next>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|||||||
+1142
-141
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+112
-26
@@ -9,14 +9,13 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family:
|
font-family:
|
||||||
"IBM Plex Sans",
|
"Poppins",
|
||||||
"Avenir Next",
|
"Avenir Next",
|
||||||
"Segoe UI",
|
"Segoe UI",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(241, 196, 110, 0.24), transparent 28%),
|
radial-gradient(circle at top left, rgba(245, 158, 11, 0.12), transparent 24%),
|
||||||
radial-gradient(circle at top right, rgba(157, 217, 210, 0.16), transparent 34%),
|
linear-gradient(180deg, #fffdfa 0%, #faf7f1 100%);
|
||||||
linear-gradient(180deg, #fbfaf6 0%, #f5efe3 55%, #f7f3ea 100%);
|
|
||||||
@apply text-stone-900;
|
@apply text-stone-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,28 +23,28 @@
|
|||||||
h2,
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
font-family:
|
font-family:
|
||||||
"Cormorant Garamond",
|
"Poppins",
|
||||||
"IBM Plex Sans",
|
"Avenir Next",
|
||||||
serif;
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.site-container {
|
.site-container {
|
||||||
@apply mx-auto w-full max-w-7xl px-4 sm:px-5 lg:px-8;
|
@apply mx-auto w-full max-w-[104rem] px-4 sm:px-5 lg:px-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
@apply sticky top-0 z-40 backdrop-blur;
|
@apply sticky top-0 z-40 bg-white/95 backdrop-blur;
|
||||||
}
|
}
|
||||||
|
|
||||||
.utility-bar {
|
.utility-bar {
|
||||||
background: linear-gradient(90deg, rgba(20, 33, 61, 0.98), rgba(37, 58, 89, 0.94));
|
@apply border-b border-stone-200 bg-white text-stone-700;
|
||||||
@apply border-b border-white/10 text-stone-100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-locale {
|
.header-locale {
|
||||||
@apply flex w-full flex-wrap items-center justify-start gap-2 text-sm text-stone-100 sm:w-auto sm:justify-end;
|
@apply flex w-full flex-wrap items-center justify-start gap-2 text-sm text-stone-700 sm:w-auto sm:justify-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-picker {
|
.locale-picker {
|
||||||
@@ -53,11 +52,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.locale-picker__summary {
|
.locale-picker__summary {
|
||||||
@apply flex w-full cursor-pointer list-none items-center justify-between gap-2 rounded-full border border-white/0 px-3 py-1.5 text-stone-100 transition hover:border-white/20 hover:bg-white/10 hover:text-white sm:w-auto sm:justify-start;
|
@apply flex w-full cursor-pointer list-none items-center justify-between gap-2 rounded-full border border-stone-200 px-3 py-1.5 text-stone-700 transition hover:border-stone-300 hover:bg-stone-50 hover:text-stone-900 sm:w-auto sm:justify-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-picker[open] .locale-picker__summary {
|
.locale-picker[open] .locale-picker__summary {
|
||||||
@apply border-white/20 bg-white/10 text-white;
|
@apply border-stone-300 bg-stone-50 text-stone-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-picker__summary::-webkit-details-marker {
|
.locale-picker__summary::-webkit-details-marker {
|
||||||
@@ -71,7 +70,7 @@
|
|||||||
.locale-picker__code,
|
.locale-picker__code,
|
||||||
.locale-picker__item-meta,
|
.locale-picker__item-meta,
|
||||||
.locale-picker__chevron {
|
.locale-picker__chevron {
|
||||||
@apply text-[0.74rem] uppercase tracking-[0.16em] text-stone-300/80;
|
@apply text-[0.74rem] uppercase tracking-[0.16em] text-stone-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-picker__panel {
|
.locale-picker__panel {
|
||||||
@@ -91,16 +90,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
background: rgba(255, 251, 245, 0.82);
|
@apply border-b border-stone-200 bg-white shadow-[0_8px_24px_rgba(20,33,61,0.05)];
|
||||||
@apply border-b border-white/60 shadow-[0_10px_30px_rgba(20,33,61,0.08)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-bar {
|
.header-bar {
|
||||||
@apply flex flex-wrap items-center gap-4 py-4 sm:gap-5 sm:py-5 lg:flex-nowrap;
|
@apply flex flex-wrap items-center gap-3 py-3 sm:gap-5 sm:py-5 lg:flex-nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
@apply inline-flex items-end gap-0.5 text-4xl font-black leading-none tracking-tight text-black transition-transform duration-300 hover:-translate-y-0.5 sm:text-5xl;
|
@apply inline-flex items-end gap-0.5 text-4xl font-black leading-none tracking-tight text-black transition-transform duration-300 hover:-translate-y-0.5 sm:text-[3.2rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark__accent {
|
.brand-mark__accent {
|
||||||
@@ -108,11 +106,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-toggle {
|
.menu-toggle {
|
||||||
@apply inline-flex h-11 items-center justify-center rounded-full border border-stone-300/70 bg-white/80 px-5 text-xs font-semibold uppercase tracking-[0.28em] text-stone-900 transition hover:border-amber-500 hover:text-amber-600;
|
@apply inline-flex h-11 items-center justify-center border border-stone-300 bg-white px-5 text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-stone-900 shadow-[0_8px_18px_rgba(20,33,61,0.05)] transition hover:border-amber-500 hover:text-amber-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-panel {
|
.menu-panel {
|
||||||
@apply order-last w-full rounded-3xl border border-white/70 bg-white/95 p-4 shadow-[0_16px_34px_rgba(20,33,61,0.12)] backdrop-blur lg:order-none lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0 lg:shadow-none;
|
@apply order-last mt-2 w-full rounded-[1.75rem] border border-stone-200 bg-white p-4 shadow-[0_16px_34px_rgba(20,33,61,0.08)] backdrop-blur lg:order-none lg:mt-0 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0 lg:shadow-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-nav {
|
.desktop-nav {
|
||||||
@@ -120,7 +118,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desktop-nav__link {
|
.desktop-nav__link {
|
||||||
@apply inline-flex items-center py-4 text-[1.04rem] font-medium text-stone-800 transition hover:text-amber-600;
|
@apply inline-flex items-center py-4 text-[1rem] font-medium text-stone-800 transition hover:text-amber-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-nav__toggle {
|
.desktop-nav__toggle {
|
||||||
@@ -133,8 +131,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mega-menu {
|
.mega-menu {
|
||||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(252, 248, 240, 0.98));
|
background: linear-gradient(180deg, rgba(255, 253, 250, 0.98), rgba(250, 247, 241, 0.98));
|
||||||
@apply absolute inset-x-0 top-full z-50 border-t border-white/70 shadow-[0_28px_60px_rgba(20,33,61,0.16)] backdrop-blur;
|
@apply absolute inset-x-0 top-full z-50 border-t border-stone-200 shadow-[0_24px_54px_rgba(20,33,61,0.1)] backdrop-blur;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mega-menu__grid {
|
.mega-menu__grid {
|
||||||
@@ -158,11 +156,60 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@apply ml-0 flex items-center gap-4 text-2xl text-stone-900 lg:ml-auto;
|
@apply order-2 ml-auto flex items-center gap-2 text-2xl text-stone-900 lg:ml-auto lg:gap-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
@apply flex h-11 w-11 items-center justify-center rounded-full bg-white/70 text-[1.35rem] shadow-[0_10px_24px_rgba(20,33,61,0.08)] transition hover:-translate-y-0.5 hover:text-amber-600;
|
@apply flex h-11 w-11 items-center justify-center rounded-full border border-transparent bg-white text-[1.2rem] shadow-[0_8px_18px_rgba(20,33,61,0.05)] transition hover:-translate-y-0.5 hover:border-stone-200 hover:text-amber-600 sm:text-[1.35rem];
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-product-gallery-main] .splide__track,
|
||||||
|
[data-gallery-main-splide] .splide__track {
|
||||||
|
@apply overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-product-gallery-thumbs] .splide__slide,
|
||||||
|
[data-gallery-thumb-splide] .splide__slide {
|
||||||
|
@apply cursor-pointer opacity-70 transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-product-gallery-thumbs] .splide__slide.is-active,
|
||||||
|
[data-gallery-thumb-splide] .splide__slide.is-active {
|
||||||
|
@apply border-amber-500 opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-product-gallery-thumbs] .splide__slide img,
|
||||||
|
[data-gallery-thumb-splide] .splide__slide img {
|
||||||
|
@apply block h-full w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description {
|
||||||
|
@apply max-w-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description p:first-child {
|
||||||
|
@apply mt-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description p:last-child {
|
||||||
|
@apply mb-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description p + p {
|
||||||
|
@apply mt-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description ul,
|
||||||
|
.category-description ol {
|
||||||
|
@apply my-4 pl-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description li + li {
|
||||||
|
@apply mt-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description a {
|
||||||
|
@apply text-amber-600 underline underline-offset-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
@@ -170,6 +217,45 @@
|
|||||||
.mega-menu {
|
.mega-menu {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.utility-bar .site-container {
|
||||||
|
@apply gap-2 py-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.utility-bar a[href^="mailto:"] {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-locale {
|
||||||
|
@apply grid w-full grid-cols-2 gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker,
|
||||||
|
.locale-picker__summary {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
@apply text-[2.2rem];
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar {
|
||||||
|
@apply gap-y-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-panel {
|
||||||
|
@apply max-h-[70vh] overflow-y-auto p-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.header-actions .nav-icon:first-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle {
|
||||||
|
@apply px-4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button[type='submit'] {
|
button[type='submit'] {
|
||||||
|
|||||||
+703
@@ -106,3 +106,706 @@ if (cartButton) {
|
|||||||
root.style.setProperty("--cta-glow", "0 0 0 0 rgba(0,0,0,0)");
|
root.style.setProperty("--cta-glow", "0 0 0 0 rgba(0,0,0,0)");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const attachSwipeNavigation = (element, onPrev, onNext, options = {}) => {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = options.threshold ?? 48;
|
||||||
|
const getAxis = () => (typeof options.axis === "function" ? options.axis() : options.axis ?? "x");
|
||||||
|
const allowMouse = options.allowMouse ?? true;
|
||||||
|
const suppressClickAfterSwipe = options.suppressClickAfterSwipe ?? false;
|
||||||
|
let pointerId = null;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let deltaX = 0;
|
||||||
|
let deltaY = 0;
|
||||||
|
let moved = false;
|
||||||
|
let swallowClick = false;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
pointerId = null;
|
||||||
|
startX = 0;
|
||||||
|
startY = 0;
|
||||||
|
deltaX = 0;
|
||||||
|
deltaY = 0;
|
||||||
|
moved = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener("pointerdown", (event) => {
|
||||||
|
if (!allowMouse && event.pointerType === "mouse") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.pointerType === "mouse" && event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pointerId = event.pointerId;
|
||||||
|
startX = event.clientX;
|
||||||
|
startY = event.clientY;
|
||||||
|
deltaX = 0;
|
||||||
|
deltaY = 0;
|
||||||
|
moved = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener("pointermove", (event) => {
|
||||||
|
if (event.pointerId !== pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deltaX = event.clientX - startX;
|
||||||
|
deltaY = event.clientY - startY;
|
||||||
|
if (Math.abs(deltaX) > 6 || Math.abs(deltaY) > 6) {
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const finish = (event) => {
|
||||||
|
if (event.pointerId !== pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const axis = getAxis();
|
||||||
|
const primaryDelta = axis === "y" ? deltaY : deltaX;
|
||||||
|
const crossDelta = axis === "y" ? deltaX : deltaY;
|
||||||
|
|
||||||
|
if (moved && Math.abs(primaryDelta) >= threshold && Math.abs(primaryDelta) > Math.abs(crossDelta)) {
|
||||||
|
swallowClick = suppressClickAfterSwipe;
|
||||||
|
if (primaryDelta > 0) {
|
||||||
|
onPrev();
|
||||||
|
} else {
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener("pointerup", finish);
|
||||||
|
element.addEventListener("pointercancel", reset);
|
||||||
|
element.addEventListener("pointerleave", (event) => {
|
||||||
|
if (event.pointerId === pointerId && event.pointerType !== "touch") {
|
||||||
|
finish(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (suppressClickAfterSwipe) {
|
||||||
|
element.addEventListener(
|
||||||
|
"click",
|
||||||
|
(event) => {
|
||||||
|
if (!swallowClick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
swallowClick = false;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachDragScroll = (element, options = {}) => {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAxis = () => (typeof options.axis === "function" ? options.axis() : options.axis ?? "x");
|
||||||
|
let pointerId = null;
|
||||||
|
let startPosition = 0;
|
||||||
|
let startScroll = 0;
|
||||||
|
let dragging = false;
|
||||||
|
|
||||||
|
element.addEventListener("pointerdown", (event) => {
|
||||||
|
if (event.pointerType === "mouse" && event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const axis = getAxis();
|
||||||
|
pointerId = event.pointerId;
|
||||||
|
startPosition = axis === "y" ? event.clientY : event.clientX;
|
||||||
|
startScroll = axis === "y" ? element.scrollTop : element.scrollLeft;
|
||||||
|
dragging = true;
|
||||||
|
element.setPointerCapture?.(event.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener("pointermove", (event) => {
|
||||||
|
if (!dragging || event.pointerId !== pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const axis = getAxis();
|
||||||
|
const currentPosition = axis === "y" ? event.clientY : event.clientX;
|
||||||
|
const delta = currentPosition - startPosition;
|
||||||
|
if (axis === "y") {
|
||||||
|
element.scrollTop = startScroll - delta;
|
||||||
|
} else {
|
||||||
|
element.scrollLeft = startScroll - delta;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopDragging = (event) => {
|
||||||
|
if (event.pointerId !== pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragging = false;
|
||||||
|
pointerId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener("pointerup", stopDragging);
|
||||||
|
element.addEventListener("pointercancel", stopDragging);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SplideCtor = window.Splide;
|
||||||
|
const productMainImage = document.querySelector("[data-product-main-image]");
|
||||||
|
const productGalleryRoot = document.querySelector("[data-product-gallery-main]");
|
||||||
|
const productGalleryThumbRoot = document.querySelector("[data-product-gallery-thumbs]");
|
||||||
|
const productThumbPrev = document.querySelector("[data-product-thumb-prev]");
|
||||||
|
const productThumbNext = document.querySelector("[data-product-thumb-next]");
|
||||||
|
const productGalleryImages = [...document.querySelectorAll("[data-product-gallery-image]")];
|
||||||
|
const defaultProductImage = productMainImage?.dataset.defaultImage
|
||||||
|
|| productGalleryImages[0]?.dataset.imageUrl
|
||||||
|
|| productMainImage?.getAttribute("src")
|
||||||
|
|| "";
|
||||||
|
|
||||||
|
let productMainSplide = null;
|
||||||
|
let productThumbSplide = null;
|
||||||
|
let galleryMainSplide = null;
|
||||||
|
let galleryThumbSplide = null;
|
||||||
|
|
||||||
|
const findImageIndex = (nodes, url) => nodes.findIndex((node) => (node.dataset.imageUrl || node.getAttribute("src") || "") === url);
|
||||||
|
|
||||||
|
if (SplideCtor && productGalleryRoot && productGalleryThumbRoot && productGalleryImages.length > 1) {
|
||||||
|
productMainSplide = new SplideCtor(productGalleryRoot, {
|
||||||
|
type: "slide",
|
||||||
|
arrows: false,
|
||||||
|
pagination: false,
|
||||||
|
rewind: true,
|
||||||
|
drag: true,
|
||||||
|
speed: 520,
|
||||||
|
});
|
||||||
|
|
||||||
|
productThumbSplide = new SplideCtor(productGalleryThumbRoot, {
|
||||||
|
fixedWidth: 64,
|
||||||
|
fixedHeight: 64,
|
||||||
|
gap: 12,
|
||||||
|
rewind: true,
|
||||||
|
pagination: false,
|
||||||
|
arrows: false,
|
||||||
|
isNavigation: true,
|
||||||
|
focus: "center",
|
||||||
|
dragMinThreshold: {
|
||||||
|
mouse: 4,
|
||||||
|
touch: 8,
|
||||||
|
},
|
||||||
|
breakpoints: {
|
||||||
|
640: {
|
||||||
|
fixedWidth: 64,
|
||||||
|
fixedHeight: 64,
|
||||||
|
},
|
||||||
|
1024: {
|
||||||
|
fixedWidth: 80,
|
||||||
|
fixedHeight: 80,
|
||||||
|
},
|
||||||
|
1280: {
|
||||||
|
fixedWidth: 96,
|
||||||
|
fixedHeight: 96,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
productMainSplide.sync(productThumbSplide);
|
||||||
|
productMainSplide.mount();
|
||||||
|
productThumbSplide.mount();
|
||||||
|
|
||||||
|
productThumbPrev?.addEventListener("click", () => {
|
||||||
|
productMainSplide.go("<");
|
||||||
|
});
|
||||||
|
|
||||||
|
productThumbNext?.addEventListener("click", () => {
|
||||||
|
productMainSplide.go(">");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantPicker = document.querySelector("[data-variant-picker]");
|
||||||
|
const variantCombinationInput = document.querySelector("[data-variant-combination]");
|
||||||
|
|
||||||
|
if (variantPicker && variantCombinationInput) {
|
||||||
|
const interactiveGroups = [...variantPicker.querySelectorAll("[data-variant-group]")];
|
||||||
|
const variantSummary = document.querySelector("[data-variant-selection-summary]");
|
||||||
|
const productPriceGross = document.querySelector("[data-product-price-gross]");
|
||||||
|
const productPriceNet = document.querySelector("[data-product-price-net]");
|
||||||
|
const defaultPriceGross = productPriceGross?.dataset.defaultPriceGross || productPriceGross?.textContent || "";
|
||||||
|
const defaultPriceNet = productPriceNet?.dataset.defaultPriceNet || productPriceNet?.textContent || "";
|
||||||
|
const combinationImageNodes = [...document.querySelectorAll("[data-variant-combination-image]")];
|
||||||
|
const combinationImageByID = new Map(
|
||||||
|
combinationImageNodes.map((node) => [
|
||||||
|
node.dataset.variantCombinationImage,
|
||||||
|
{
|
||||||
|
imageLarge: node.dataset.imageLarge || "",
|
||||||
|
priceGross: node.dataset.priceGross || "",
|
||||||
|
priceNet: node.dataset.priceNet || "",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const parseCombinationIDs = (value) =>
|
||||||
|
(value || "")
|
||||||
|
.split(",")
|
||||||
|
.map((item) => Number.parseInt(item, 10))
|
||||||
|
.filter((item) => Number.isInteger(item) && item > 0);
|
||||||
|
|
||||||
|
const optionClassName = (presentation, selected, disabled) => {
|
||||||
|
if (presentation === "radio") {
|
||||||
|
if (disabled) {
|
||||||
|
return "rounded-full border border-stone-200 bg-stone-100 px-4 py-2 text-sm font-medium text-stone-400 opacity-50 transition";
|
||||||
|
}
|
||||||
|
if (selected) {
|
||||||
|
return "rounded-full border border-stone-900 bg-stone-900 px-4 py-2 text-sm font-medium text-stone-50 transition";
|
||||||
|
}
|
||||||
|
return "rounded-full border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-900 transition hover:border-stone-700";
|
||||||
|
}
|
||||||
|
if (presentation === "select") {
|
||||||
|
if (disabled) {
|
||||||
|
return "w-full rounded-2xl px-4 py-3 text-left text-sm font-medium text-stone-400 opacity-50 transition";
|
||||||
|
}
|
||||||
|
if (selected) {
|
||||||
|
return "w-full rounded-2xl bg-stone-900 px-4 py-3 text-left text-sm font-medium text-stone-50 transition";
|
||||||
|
}
|
||||||
|
return "w-full rounded-2xl px-4 py-3 text-left text-sm font-medium text-stone-700 transition hover:bg-stone-100 hover:text-stone-950";
|
||||||
|
}
|
||||||
|
if (disabled) {
|
||||||
|
return "inline-flex min-h-10 min-w-10 items-center justify-center border border-stone-200 p-0.5 opacity-40 transition";
|
||||||
|
}
|
||||||
|
if (selected) {
|
||||||
|
return "inline-flex min-h-10 min-w-10 items-center justify-center border border-stone-900 p-0.5 ring-1 ring-stone-900 transition";
|
||||||
|
}
|
||||||
|
return "inline-flex min-h-10 min-w-10 items-center justify-center border border-stone-300 p-0.5 transition hover:border-stone-700";
|
||||||
|
};
|
||||||
|
|
||||||
|
const intersectIDs = (left, right) => left.filter((id) => right.includes(id));
|
||||||
|
|
||||||
|
const availableCombinationIDsForGroup = (targetGroup) => {
|
||||||
|
let matched = null;
|
||||||
|
|
||||||
|
interactiveGroups.forEach((group) => {
|
||||||
|
if (group === targetGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeButton = group.querySelector("[data-variant-option][data-selected='true']");
|
||||||
|
const ids = parseCombinationIDs(activeButton?.dataset.combinationIds);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matched === null) {
|
||||||
|
matched = ids;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
matched = intersectIDs(matched, ids);
|
||||||
|
});
|
||||||
|
|
||||||
|
return matched;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedValueForGroup = (group) => {
|
||||||
|
if (group.dataset.variantSelect !== undefined) {
|
||||||
|
const triggerValue = group.querySelector("[data-variant-select-value]");
|
||||||
|
return triggerValue?.textContent?.trim() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeButton = group.querySelector("[data-variant-option][data-selected='true']");
|
||||||
|
return activeButton?.getAttribute("aria-label")?.trim() || activeButton?.textContent?.trim() || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSelectionSummary = () => {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
interactiveGroups.forEach((group) => {
|
||||||
|
const key = group.dataset.variantGroup;
|
||||||
|
const value = selectedValueForGroup(group);
|
||||||
|
const labelNode = key
|
||||||
|
? document.querySelector(`[data-variant-current="${key}"]`)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (labelNode) {
|
||||||
|
labelNode.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = group.closest(".space-y-3");
|
||||||
|
const label = wrapper?.querySelector("p")?.textContent?.trim() || "";
|
||||||
|
if (label && value) {
|
||||||
|
parts.push(`${label}: ${value}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (variantSummary) {
|
||||||
|
variantSummary.textContent = parts.length > 0 ? parts.join(" • ") : "Choose product options";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateButtonSelection = (group, activeButton) => {
|
||||||
|
group.querySelectorAll("[data-variant-option]").forEach((button) => {
|
||||||
|
const isActive = button === activeButton;
|
||||||
|
button.dataset.selected = isActive ? "true" : "false";
|
||||||
|
const disabled = button.dataset.disabled === "true";
|
||||||
|
button.className = optionClassName(button.dataset.variantPresentation, isActive, disabled);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncSelectTriggerValue = (group) => {
|
||||||
|
if (group.dataset.variantSelect === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valueNode = group.querySelector("[data-variant-select-value]");
|
||||||
|
const activeButton = group.querySelector("[data-variant-option][data-selected='true']");
|
||||||
|
if (valueNode && activeButton) {
|
||||||
|
valueNode.textContent = activeButton.textContent?.trim() || "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshGroupAvailability = (group) => {
|
||||||
|
const availableIDs = availableCombinationIDsForGroup(group);
|
||||||
|
const options = [...group.querySelectorAll("[data-variant-option]")];
|
||||||
|
|
||||||
|
options.forEach((button) => {
|
||||||
|
const optionIDs = parseCombinationIDs(button.dataset.combinationIds);
|
||||||
|
const enabled = availableIDs === null || intersectIDs(optionIDs, availableIDs).length > 0;
|
||||||
|
button.dataset.disabled = enabled ? "false" : "true";
|
||||||
|
button.disabled = !enabled;
|
||||||
|
button.setAttribute("aria-disabled", enabled ? "false" : "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedButton = group.querySelector("[data-variant-option][data-selected='true']");
|
||||||
|
if (selectedButton?.dataset.disabled === "true") {
|
||||||
|
selectedButton.dataset.selected = "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextSelected = group.querySelector("[data-variant-option][data-selected='true'][data-disabled='false']");
|
||||||
|
if (!nextSelected) {
|
||||||
|
nextSelected = group.querySelector("[data-variant-option][data-disabled='false']");
|
||||||
|
}
|
||||||
|
if (nextSelected) {
|
||||||
|
updateButtonSelection(group, nextSelected);
|
||||||
|
syncSelectTriggerValue(group);
|
||||||
|
} else {
|
||||||
|
options.forEach((button) => {
|
||||||
|
button.dataset.selected = "false";
|
||||||
|
button.className = optionClassName(button.dataset.variantPresentation, false, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeVariantSelections = () => {
|
||||||
|
for (let pass = 0; pass < 2; pass += 1) {
|
||||||
|
interactiveGroups.forEach((group) => {
|
||||||
|
refreshGroupAvailability(group);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCombination = () => {
|
||||||
|
normalizeVariantSelections();
|
||||||
|
|
||||||
|
let matched = null;
|
||||||
|
|
||||||
|
interactiveGroups.forEach((group) => {
|
||||||
|
const activeButton = group.querySelector("[data-variant-option][data-selected='true']");
|
||||||
|
const ids = parseCombinationIDs(activeButton?.dataset.combinationIds);
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
matched = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched === null) {
|
||||||
|
matched = ids;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matched = matched.filter((id) => ids.includes(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matched && matched.length > 0) {
|
||||||
|
const combinationID = String(matched[0]);
|
||||||
|
variantCombinationInput.value = combinationID;
|
||||||
|
const combinationData = combinationImageByID.get(combinationID);
|
||||||
|
const nextImage = combinationData?.imageLarge || defaultProductImage;
|
||||||
|
if (nextImage) {
|
||||||
|
if (productMainSplide && productGalleryImages.length > 0) {
|
||||||
|
const matchingIndex = findImageIndex(productGalleryImages, nextImage);
|
||||||
|
if (matchingIndex >= 0) {
|
||||||
|
productMainSplide.go(matchingIndex);
|
||||||
|
}
|
||||||
|
} else if (productMainImage) {
|
||||||
|
productMainImage.setAttribute("src", nextImage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (productPriceGross) {
|
||||||
|
productPriceGross.textContent = combinationData?.priceGross || defaultPriceGross;
|
||||||
|
}
|
||||||
|
if (productPriceNet) {
|
||||||
|
productPriceNet.textContent = combinationData?.priceNet || defaultPriceNet;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
variantCombinationInput.value = "";
|
||||||
|
if (productPriceGross) {
|
||||||
|
productPriceGross.textContent = defaultPriceGross;
|
||||||
|
}
|
||||||
|
if (productPriceNet) {
|
||||||
|
productPriceNet.textContent = defaultPriceNet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectionSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeVariantSelects = () => {
|
||||||
|
interactiveGroups.forEach((group) => {
|
||||||
|
if (group.dataset.variantSelect === undefined) return;
|
||||||
|
group.querySelector("[data-variant-select-menu]")?.classList.add("hidden");
|
||||||
|
group.querySelector("[data-variant-select-trigger]")?.setAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interactiveGroups.forEach((group) => {
|
||||||
|
if (group.dataset.variantSelect !== undefined) {
|
||||||
|
const trigger = group.querySelector("[data-variant-select-trigger]");
|
||||||
|
const menu = group.querySelector("[data-variant-select-menu]");
|
||||||
|
const valueNode = group.querySelector("[data-variant-select-value]");
|
||||||
|
|
||||||
|
trigger?.addEventListener("click", () => {
|
||||||
|
const isOpen = trigger.getAttribute("aria-expanded") === "true";
|
||||||
|
closeVariantSelects();
|
||||||
|
if (isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
menu?.classList.remove("hidden");
|
||||||
|
trigger.setAttribute("aria-expanded", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
group.querySelectorAll("[data-variant-option]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (button.dataset.disabled === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateButtonSelection(group, button);
|
||||||
|
if (valueNode) {
|
||||||
|
valueNode.textContent = button.textContent?.trim() || "";
|
||||||
|
}
|
||||||
|
closeVariantSelects();
|
||||||
|
resolveCombination();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.querySelectorAll("[data-variant-option]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (button.dataset.disabled === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateButtonSelection(group, button);
|
||||||
|
resolveCombination();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
resolveCombination();
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Node)) return;
|
||||||
|
const insidePicker = variantPicker.contains(target);
|
||||||
|
if (!insidePicker) {
|
||||||
|
closeVariantSelects();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeVariantSelects();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const galleryModal = document.querySelector("[data-gallery-modal]");
|
||||||
|
const galleryMain = galleryModal?.querySelector("[data-gallery-main]");
|
||||||
|
const galleryOpeners = [...document.querySelectorAll("[data-gallery-open]")];
|
||||||
|
const galleryClosers = [...document.querySelectorAll("[data-gallery-close]")];
|
||||||
|
const galleryMainRoot = galleryModal?.querySelector("[data-gallery-main-splide]");
|
||||||
|
const galleryThumbRoot = galleryModal?.querySelector("[data-gallery-thumb-splide]");
|
||||||
|
const galleryImages = [...document.querySelectorAll("[data-gallery-image]")];
|
||||||
|
const galleryPrev = galleryModal?.querySelector("[data-gallery-prev]");
|
||||||
|
const galleryNext = galleryModal?.querySelector("[data-gallery-next]");
|
||||||
|
const galleryThumbPrev = galleryModal?.querySelector("[data-gallery-thumb-prev]");
|
||||||
|
const galleryThumbNext = galleryModal?.querySelector("[data-gallery-thumb-next]");
|
||||||
|
|
||||||
|
if (SplideCtor && galleryMainRoot && galleryThumbRoot && galleryImages.length > 1) {
|
||||||
|
galleryMainSplide = new SplideCtor(galleryMainRoot, {
|
||||||
|
type: "slide",
|
||||||
|
arrows: false,
|
||||||
|
pagination: false,
|
||||||
|
rewind: true,
|
||||||
|
drag: true,
|
||||||
|
speed: 520,
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryThumbSplide = new SplideCtor(galleryThumbRoot, {
|
||||||
|
fixedWidth: 64,
|
||||||
|
fixedHeight: 64,
|
||||||
|
gap: 12,
|
||||||
|
rewind: true,
|
||||||
|
pagination: false,
|
||||||
|
arrows: false,
|
||||||
|
isNavigation: true,
|
||||||
|
focus: "center",
|
||||||
|
dragMinThreshold: {
|
||||||
|
mouse: 4,
|
||||||
|
touch: 8,
|
||||||
|
},
|
||||||
|
breakpoints: {
|
||||||
|
640: {
|
||||||
|
fixedWidth: 64,
|
||||||
|
fixedHeight: 64,
|
||||||
|
},
|
||||||
|
1024: {
|
||||||
|
fixedWidth: 80,
|
||||||
|
fixedHeight: 80,
|
||||||
|
},
|
||||||
|
1280: {
|
||||||
|
fixedWidth: 96,
|
||||||
|
fixedHeight: 96,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryMainSplide.sync(galleryThumbSplide);
|
||||||
|
galleryMainSplide.mount();
|
||||||
|
galleryThumbSplide.mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (galleryModal && (galleryMain || galleryMainSplide) && galleryOpeners.length > 0) {
|
||||||
|
let wheelLocked = false;
|
||||||
|
|
||||||
|
const currentProductImageURL = () => {
|
||||||
|
if (productMainSplide && productGalleryImages.length > 0) {
|
||||||
|
return productGalleryImages[productMainSplide.index]?.dataset.imageUrl || defaultProductImage;
|
||||||
|
}
|
||||||
|
return productMainImage?.getAttribute("src") || defaultProductImage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showGalleryImage = (index) => {
|
||||||
|
if (galleryMainSplide && galleryImages.length > 0) {
|
||||||
|
const normalizedIndex = (index + galleryImages.length) % galleryImages.length;
|
||||||
|
galleryMainSplide.go(normalizedIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!galleryMain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextImage = galleryImages[index];
|
||||||
|
const nextSrc = nextImage?.dataset.imageUrl || "";
|
||||||
|
if (!nextSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
galleryMain.setAttribute("src", nextSrc);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepGallery = (direction) => {
|
||||||
|
if (galleryMainSplide) {
|
||||||
|
galleryMainSplide.go(direction > 0 ? ">" : "<");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showGalleryImage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openGallery = (index) => {
|
||||||
|
const nextIndex = Number.isInteger(index) ? index : findImageIndex(galleryImages, currentProductImageURL());
|
||||||
|
if (nextIndex >= 0) {
|
||||||
|
showGalleryImage(nextIndex);
|
||||||
|
}
|
||||||
|
galleryModal.classList.remove("hidden");
|
||||||
|
galleryModal.setAttribute("aria-hidden", "false");
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeGallery = () => {
|
||||||
|
galleryModal.classList.add("hidden");
|
||||||
|
galleryModal.setAttribute("aria-hidden", "true");
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
galleryOpeners.forEach((trigger) => {
|
||||||
|
trigger.addEventListener("click", () => {
|
||||||
|
const index = Number.parseInt(trigger.dataset.galleryIndex || "", 10);
|
||||||
|
openGallery(Number.isInteger(index) ? index : undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryClosers.forEach((trigger) => {
|
||||||
|
trigger.addEventListener("click", closeGallery);
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryPrev?.addEventListener("click", () => {
|
||||||
|
stepGallery(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryNext?.addEventListener("click", () => {
|
||||||
|
stepGallery(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryThumbPrev?.addEventListener("click", () => {
|
||||||
|
galleryMainSplide?.go("<");
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryThumbNext?.addEventListener("click", () => {
|
||||||
|
galleryMainSplide?.go(">");
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryModal.addEventListener("click", (event) => {
|
||||||
|
if (event.target === galleryModal) {
|
||||||
|
closeGallery();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryModal.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(event) => {
|
||||||
|
if (galleryModal.getAttribute("aria-hidden") !== "false" || !galleryMainSplide) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Math.abs(event.deltaY) < 10) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (wheelLocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wheelLocked = true;
|
||||||
|
stepGallery(event.deltaY > 0 ? 1 : -1);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
wheelLocked = false;
|
||||||
|
}, 180);
|
||||||
|
},
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (galleryModal.getAttribute("aria-hidden") !== "false") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeGallery();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
stepGallery(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
stepGallery(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user