diff --git a/README.md b/README.md index b90a8d4..663952e 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,10 @@ Go reverse proxy in front of PrestaShop `1.7.3` with: ## Current scope - Go owns `GET /product/:slug` +- Go owns `GET /cart` - all other routes proxy to the upstream PrestaShop instance - 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 ## Requirements diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 530ce8d..6bfbff4 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -92,6 +92,7 @@ func run() error { cartService, render.New(assetManifest), cfg, + productRoute, categoryRoute, ) categoryHandler := handlers.NewCategoryHandler( @@ -103,6 +104,16 @@ func run() error { productRoute, 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) if err != nil { @@ -125,6 +136,10 @@ func run() error { e.GET("/healthz", handlers.Healthz()) 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 { productMatch, productOK := productRoute.MatchInfo(c.Request().URL.Path) categoryMatch, categoryOK := categoryRoute.MatchInfo(c.Request().URL.Path) diff --git a/internal/http/handlers/cart.go b/internal/http/handlers/cart.go new file mode 100644 index 0000000..5035c40 --- /dev/null +++ b/internal/http/handlers/cart.go @@ -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" +} diff --git a/internal/http/handlers/cart_page.go b/internal/http/handlers/cart_page.go new file mode 100644 index 0000000..796f832 --- /dev/null +++ b/internal/http/handlers/cart_page.go @@ -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") + } +} diff --git a/internal/http/handlers/cart_test.go b/internal/http/handlers/cart_test.go new file mode 100644 index 0000000..e9b62b5 --- /dev/null +++ b/internal/http/handlers/cart_test.go @@ -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) + } +} diff --git a/internal/http/handlers/category.go b/internal/http/handlers/category.go index ce12b9b..48e6068 100644 --- a/internal/http/handlers/category.go +++ b/internal/http/handlers/category.go @@ -3,6 +3,8 @@ package handlers import ( "errors" "net/http" + "net/url" + "strconv" "strings" "github.com/labstack/echo/v4" @@ -52,12 +54,16 @@ func (h *CategoryHandler) Show(c echo.Context) error { 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) category, err := h.catalog.GetCategoryPage(c.Request().Context(), pscatalog.CategoryPageRequest{ ID: categoryID(c), Slug: categorySlug(c), LanguageID: languageID, ShopID: shopID, + CurrencyID: currencyID, + Page: categoryPageParam(c.Request()), + PerPage: 30, }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -89,7 +95,9 @@ func (h *CategoryHandler) Show(c echo.Context) error { CartSummary: cartSummary, ShopBaseURL: h.config.PrestaShopBaseURL, } + page.Pagination = categoryPaginationView(c.Request(), category.Pagination) assignCategoryProductLinks(c.Request(), h.products, &page) + assignCategoryProductImages(requestBaseURL(c.Request()), &page) menu, err := loadMenu(c.Request(), h.catalog, h.categories, languageID, shopID) if err != nil { 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 { 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) - categoryPath := page.Category.Slug - for i := range page.Category.Products { - product := &page.Category.Products[i] + for i := range products { + product := &products[i] + categoryPath := strings.TrimSpace(product.CategorySlug) + if categoryPath == "" { + categoryPath = fallbackCategoryPath + } product.URL = route.BuildPath(psroutes.ProductURLData{ ID: product.ID, 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 +} diff --git a/internal/http/handlers/cookie_debug_test.go b/internal/http/handlers/cookie_debug_test.go index 5f63c82..eff2f42 100644 --- a/internal/http/handlers/cookie_debug_test.go +++ b/internal/http/handlers/cookie_debug_test.go @@ -14,17 +14,31 @@ import ( const ( testCookieKey = "def000008bf3d70e7012b7493c382d561e193218d0c74ab162fb0ea8029ce20e926531b4bcf0aaec9381152e6c161f198e06918b2d1aad67cc7cf40819a51ee328c63830" - testCookie = "def5020099dce5cd9ecf197adb5532a74e3db2ed9cba3d59b98f365353099b710bd562efa48b6bad1ad0a12b2ee54de0fbfcc6baa0545a8234141b03bfc1fbbbb9061af5011764b9c4dfd9c0ddcad767a453e0cc24d6b4a7c524e6c49aabd66ecc390e1a964b6e81a051b171051c829542facbb36cf64fcfebf069906dcc95476578be3fe59aaae466cf70bd9c877d301d908ec3aa4f55366567f460dfefac1684ce381293e8d4138382a42716d6aaecdcc7" ) 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) @@ -55,10 +69,25 @@ 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 { diff --git a/internal/http/handlers/images.go b/internal/http/handlers/images.go new file mode 100644 index 0000000..d5713f9 --- /dev/null +++ b/internal/http/handlers/images.go @@ -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 +} diff --git a/internal/http/handlers/product.go b/internal/http/handlers/product.go index be27844..5b54fc3 100644 --- a/internal/http/handlers/product.go +++ b/internal/http/handlers/product.go @@ -1,6 +1,7 @@ package handlers import ( + "database/sql" "errors" "net/http" "strings" @@ -27,16 +28,18 @@ type ProductHandler struct { carts *pscart.Service renderer *render.Engine config psconfig.Config + productURL *psroutes.ProductRoute 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{ products: products, customers: customers, carts: carts, renderer: renderer, config: cfg, + productURL: productURL, categories: categories, } } @@ -50,12 +53,14 @@ func (h *ProductHandler) Show(c echo.Context) error { languageID := int64Default(session.LanguageID, 1) languageID = h.products.ResolveLanguageID(c.Request().Context(), c.Request(), languageID) shopID := int64Default(session.ShopID, 1) + currencyID := int64Default(session.CurrencyID, 1) product, err := h.products.GetProductPage(c.Request().Context(), pscatalog.ProductPageRequest{ ID: productID(c), Slug: productSlug(c), LanguageID: languageID, ShopID: shopID, + CurrencyID: currencyID, }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -63,6 +68,14 @@ func (h *ProductHandler) Show(c echo.Context) error { } 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 if session.CustomerID != nil && h.customers != nil { @@ -145,3 +158,31 @@ func productCategoryURL(req *http.Request, route *psroutes.CategoryRoute, produc 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} +} diff --git a/internal/prestashop/cart/service.go b/internal/prestashop/cart/service.go index bbcde99..87a239a 100644 --- a/internal/prestashop/cart/service.go +++ b/internal/prestashop/cart/service.go @@ -2,7 +2,12 @@ package cart import ( "context" + "database/sql" + "errors" "fmt" + "strconv" + "strings" + "time" "gorm.io/gorm" ) @@ -12,6 +17,63 @@ type Summary struct { 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 { db *gorm.DB prefix string @@ -33,3 +95,676 @@ func (s *Service) SummaryByID(ctx context.Context, cartID int64) (*Summary, erro } 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) +} diff --git a/internal/prestashop/catalog/service.go b/internal/prestashop/catalog/service.go index 0be55ce..64cd877 100644 --- a/internal/prestashop/catalog/service.go +++ b/internal/prestashop/catalog/service.go @@ -15,6 +15,7 @@ type ProductPageRequest struct { Slug string LanguageID int64 ShopID int64 + CurrencyID int64 } type CategoryPageRequest struct { @@ -22,6 +23,9 @@ type CategoryPageRequest struct { Slug string LanguageID int64 ShopID int64 + CurrencyID int64 + Page int + PerPage int } type ProductPageData struct { @@ -31,10 +35,55 @@ type ProductPageData struct { ShortDescription string Description string 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 + ImageURL string `gorm:"-"` + GalleryImages []ProductImage `gorm:"-"` CategoryID int64 CategorySlug 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 { @@ -43,16 +92,33 @@ type CategoryPageData struct { Slug string Description string Products []CategoryProductCard `gorm:"-"` + Pagination CategoryPagination `gorm:"-"` +} + +type CategoryPagination struct { + Page int + PerPage int + TotalItems int64 + TotalPages int } type CategoryProductCard struct { - ID int64 - Name string - Slug string - URL string `gorm:"-"` - Price float64 - Description string - EAN13 string + ID int64 + Name string + Slug string + CategorySlug string `gorm:"column:category_slug"` + URL string `gorm:"-"` + ImageURL string `gorm:"-"` + 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 `gorm:"column:cover_image_id"` + ShortDescription string `gorm:"column:short_description"` + EAN13 string } type MenuItem struct { @@ -92,54 +158,85 @@ func NewService(db *gorm.DB, prefix string) *Service { func (s *Service) GetProductPage(ctx context.Context, req ProductPageRequest) (*ProductPageData, error) { var product ProductPageData + if req.CurrencyID == 0 { + req.CurrencyID = 1 + } queryByID := fmt.Sprintf(` SELECT p.id_product AS id, pl.name AS name, pl.link_rewrite AS slug, pl.description_short AS short_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, p.id_category_default AS category_id, cl.link_rewrite AS category_slug, cl.name AS category_name FROM %sproduct p 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 %simage i ON i.id_product = p.id_product AND i.cover = 1 WHERE p.id_product = ? + AND ps.active = 1 AND pl.id_lang = ? - AND ps.id_shop = ? 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(` SELECT p.id_product AS id, pl.name AS name, pl.link_rewrite AS slug, pl.description_short AS short_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, p.id_category_default AS category_id, cl.link_rewrite AS category_slug, cl.name AS category_name FROM %sproduct p 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 %simage i ON i.id_product = p.id_product AND i.cover = 1 WHERE pl.link_rewrite = ? + AND ps.active = 1 AND pl.id_lang = ? - AND ps.id_shop = ? 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 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 { - 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 { return nil, result.Error @@ -147,12 +244,316 @@ LIMIT 1 if result.RowsAffected == 0 { 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 } +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) { 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(` SELECT c.id_category AS id, 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(` SELECT p.id_product AS id, pl.name AS name, pl.link_rewrite AS slug, p.ean13 AS ean13, - ps.price AS price, - pl.description_short AS description + (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 %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 +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 = ? + AND ps.active = 1 AND pl.id_lang = ? - AND ps.id_shop = ? ORDER BY cp.position ASC, p.id_product ASC -LIMIT 48 -`, s.prefix, s.prefix, s.prefix, s.prefix) +LIMIT ? +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 } @@ -266,6 +709,17 @@ func (s *Service) ResolveLanguageID(ctx context.Context, req *http.Request, fall 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) { rootCategoryID, err := s.rootCategoryID(ctx) if err != nil { diff --git a/internal/prestashop/routes/service.go b/internal/prestashop/routes/service.go index a81fc32..b31e88c 100644 --- a/internal/prestashop/routes/service.go +++ b/internal/prestashop/routes/service.go @@ -129,6 +129,9 @@ func (r *CategoryRoute) Match(path string) (slug string, ok bool) { } func (r *CategoryRoute) MatchInfo(path string) (*CategoryMatch, bool) { + if hasExcludedStaticSegment(path) { + return nil, false + } if r == nil || r.regex == nil { 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) { + if hasExcludedStaticSegment(path) { + return nil, false + } if r == nil || r.regex == nil { return fallbackProductMatch(path) } @@ -500,6 +506,9 @@ func fallbackProductMatch(path string) (*ProductMatch, bool) { if path == "" { return nil, false } + if hasExcludedStaticSegment(path) { + return nil, false + } if hasExcludedContentSegment(path) { return nil, false } @@ -543,6 +552,9 @@ func fallbackCategoryMatch(path string) (*CategoryMatch, bool) { if path == "" { return nil, false } + if hasExcludedStaticSegment(path) { + return nil, false + } if hasExcludedContentSegment(path) { return nil, false } @@ -601,3 +613,19 @@ func hasExcludedContentSegment(path string) bool { } 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") +} diff --git a/internal/prestashop/routes/service_test.go b/internal/prestashop/routes/service_test.go new file mode 100644 index 0000000..b06f8bf --- /dev/null +++ b/internal/prestashop/routes/service_test.go @@ -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) + } +} diff --git a/internal/render/engine.go b/internal/render/engine.go index e61e77b..8a8522a 100644 --- a/internal/render/engine.go +++ b/internal/render/engine.go @@ -37,6 +37,12 @@ func (e *Engine) Category(w http.ResponseWriter, r *http.Request, data viewmodel 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) { if w == nil { return diff --git a/internal/viewmodel/cart.go b/internal/viewmodel/cart.go new file mode 100644 index 0000000..d595880 --- /dev/null +++ b/internal/viewmodel/cart.go @@ -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 +} diff --git a/internal/viewmodel/category.go b/internal/viewmodel/category.go index 8a1ec9b..6021e26 100644 --- a/internal/viewmodel/category.go +++ b/internal/viewmodel/category.go @@ -9,6 +9,7 @@ import ( type CategoryPageData struct { Category pscatalog.CategoryPageData + Pagination CategoryPagination Menu []pscatalog.MenuItem Locale pscatalog.HeaderLocaleData Session *pscookie.SessionContext @@ -16,3 +17,12 @@ type CategoryPageData struct { CartSummary *pscart.Summary ShopBaseURL string } + +type CategoryPagination struct { + Page int + PerPage int + TotalItems int64 + TotalPages int + PrevURL string + NextURL string +} diff --git a/templates/cart.templ b/templates/cart.templ new file mode 100644 index 0000000..7bef0d2 --- /dev/null +++ b/templates/cart.templ @@ -0,0 +1,157 @@ +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) { +
+
+
+
+
+
+
+
+

Cart overview

+

Everything ready for checkout.

+

Review quantities, adjust variants, and keep the final order state visible before handing off checkout to PrestaShop.

+
+
+
+

Line items

+

{ fmt.Sprintf("%d", len(data.Cart.Items)) }

+
+
+

Units total

+

{ fmt.Sprintf("%d", data.Cart.TotalItems) }

+
+
+
+
+ if data.Customer != nil { + { fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName) } + } else { + Guest session + } + { "Subtotal " + moneyWithCurrency(data.Cart.SubtotalTaxIncl, cartCurrencySign(data), cartCurrencyCode(data)) } +
+
+ +
+
+ if len(data.Cart.Items) == 0 { +
+

Empty

+

Your cart is empty.

+

Browse categories or return to a product page to add items and build a full order before checkout.

+
+ } else { + +
+ for _, item := range data.Cart.Items { +
+
+ if item.ImageURL != "" { +
+ { +
+ } +
+
+

Product

+ if cartItemAttributeLabel(item) != "" { + { cartItemAttributeLabel(item) } + } +
+ if item.URL != "" { + { item.Name } + } else { +

{ item.Name }

+ } +
+ { fmt.Sprintf("Qty %d", item.Quantity) } + { "Net " + moneyWithCurrency(item.UnitPrice, item.CurrencySign, item.CurrencyCode) } + { taxLabel(item.TaxRate) } +
+ if cartItemAttributeLabel(item) != "" { +

{ cartItemAttributeLabel(item) }

+ } +
+
+

Line total

+

{ moneyWithCurrency(item.LineTotalTaxIncl, item.CurrencySign, item.CurrencyCode) }

+

{ conversionRateLabel(item.ConversionRate, item.CurrencyCode) }

+
+
+
+
+ + + + + +
+ +
+ +
+
+ + + + + +
+
+
+ } +
+ } +
+ + +
+
+
+ } +} diff --git a/templates/cart_templ.go b/templates/cart_templ.go new file mode 100644 index 0000000..a14781e --- /dev/null +++ b/templates/cart_templ.go @@ -0,0 +1,565 @@ +// 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, "

Cart overview

Everything ready for checkout.

Review quantities, adjust variants, and keep the final order state visible before handing off checkout to PrestaShop.

Line items

") + 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: 26, Col: 102} + } + _, 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, "

Units total

") + 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: 30, Col: 102} + } + _, 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Customer != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + 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: 36, Col: 150} + } + _, 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, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Guest session ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + 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: 40, Col: 186} + } + _, 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Cart.Items) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Empty

Your cart is empty.

Browse categories or return to a product page to add items and build a full order before checkout.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, item := range data.Cart.Items { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if item.ImageURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
\"")
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

Product

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if cartItemAttributeLabel(item) != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + 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: 70, Col: 187} + } + _, 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if item.URL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + 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: 74, Col: 183} + } + _, 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "

") + 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: 76, Col: 78} + } + _, 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + 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: 79, Col: 125} + } + _, 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, " ") + 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: 80, Col: 169} + } + _, 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, " ") + 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: 81, Col: 111} + } + _, 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if cartItemAttributeLabel(item) != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(cartItemAttributeLabel(item)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/cart.templ`, Line: 84, Col: 82} + } + _, 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, 29, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

Line total

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, 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: 89, Col: 145} + } + _, 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, 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: 90, Col: 139} + } + _, 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, 32, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + 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 diff --git a/templates/category.templ b/templates/category.templ index dceca81..c3829c4 100644 --- a/templates/category.templ +++ b/templates/category.templ @@ -7,14 +7,14 @@ import ( ) 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)) {
-
+

Category

{ data.Category.Name }

-

{ fmt.Sprintf("Products loaded: %d", len(data.Category.Products)) }

+

{ fmt.Sprintf("Products %d-%d of %d", categoryPageStart(data.Pagination), categoryPageEnd(data.Pagination, len(data.Category.Products)), data.Pagination.TotalItems) }

if data.Customer != nil {

{ fmt.Sprintf("%s %s", data.Customer.FirstName, data.Customer.LastName) }

} else { @@ -28,14 +28,22 @@ templ CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath strin }
-
+
for _, product := range data.Category.Products {
+ if product.ImageURL != "" { + + { + + }

Product

{ product.Name }

-

{ product.Description }

+

{ truncatedPlainTextHTML(product.ShortDescription, 220) }

-

{ fmt.Sprintf("%.2f", product.Price) }

+
+

{ moneyWithCurrency(product.PriceTaxIncl, product.CurrencySign, product.CurrencyCode) }

+

{ taxLabel(product.TaxRate) } · { conversionRateLabel(product.ConversionRate, product.CurrencyCode) }

+
View Product @@ -43,26 +51,21 @@ templ CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath strin
}
- - if data.Session != nil { -
-

Go Cookie Debug

-
-
-

Raw Cookie

-
{ data.Session.RawCookie }
-
-
-

Decoded Values

-
-									for _, line := range sessionCookieLines(data.Session) {
-										{ line }
-										{"\n"}
-									}
-								
-
+ if data.Pagination.TotalPages > 1 { +
+
+ if data.Pagination.PrevURL != "" { + Previous + } + if data.Pagination.NextURL != "" { + Next + } +
+ }
diff --git a/templates/category_templ.go b/templates/category_templ.go index 7e91a66..72d9163 100644 --- a/templates/category_templ.go +++ b/templates/category_templ.go @@ -47,7 +47,7 @@ func CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath string }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Category

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Category

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -65,9 +65,9 @@ func CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath string return templ_7745c5c3_Err } 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(fmt.Sprintf("Products %d-%d of %d", categoryPageStart(data.Pagination), categoryPageEnd(data.Pagination, len(data.Category.Products)), data.Pagination.TotalItems)) 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: 17, Col: 173} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -119,126 +119,215 @@ func CategoryPage(data viewmodel.CategoryPageData, cssPath string, jsPath string return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, product := range data.Category.Products { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Product

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") 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} + if product.ImageURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

Product

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(product.Name) if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(product.Description) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 36, Col: 77} - } - _, 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, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", product.Price)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 38, Col: 89} - } - _, 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, "

View Product
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if data.Session != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

Go Cookie Debug

Raw Cookie

")
+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.Session.RawCookie) + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(truncatedPlainTextHTML(product.ShortDescription, 220)) 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: 41, Col: 111} } _, 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, 18, "

Decoded Values

")
+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - for _, line := range sessionCookieLines(data.Session) { - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(line) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 59, Col: 16} - } - _, 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, 19, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs("\n") - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/category.templ`, Line: 60, Col: 15} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, 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: 44, Col: 139} } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

") + _, 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, 19, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, 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: 45, Col: 102} + } + _, 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, 20, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, 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: 45, Col: 175} + } + _, 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, 21, "

View Product") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Pagination.TotalPages > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } 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 { return templ_7745c5c3_Err } diff --git a/templates/layout.templ b/templates/layout.templ index cb84489..4442792 100644 --- a/templates/layout.templ +++ b/templates/layout.templ @@ -2,7 +2,7 @@ package templates 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) { @@ -57,7 +57,12 @@ templ Layout(title string, cssPath string, jsPath string, menu []pscatalog.MenuI }
- 🛒 + + 🛒 + if cartItems > 0 { + { cartItems } + } +
diff --git a/templates/layout_helpers.go b/templates/layout_helpers.go index 814ce33..c9f632c 100644 --- a/templates/layout_helpers.go +++ b/templates/layout_helpers.go @@ -1,14 +1,19 @@ package templates import ( - "sort" + "fmt" + "html" + "regexp" "strconv" "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" - 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 { if depth == 0 { return "flex min-w-0 flex-col gap-2 text-sm" @@ -79,20 +84,280 @@ func menuPanelID(id int64) string { return "mega-menu-panel-" + strconv.FormatInt(id, 10) } -func sessionCookieLines(session *pscookie.SessionContext) []string { - if session == nil || len(session.Values) == 0 { - return nil +func moneyWithCurrency(amount float64, sign, code string) string { + formatted := fmt.Sprintf("%.2f", amount) + 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 { - keys = append(keys, key) - } - sort.Strings(keys) - - lines := make([]string, 0, len(keys)) - for _, key := range keys { - lines = append(lines, key+"="+session.Values[key]) - } - return lines +} + +func taxLabel(rate float64) string { + return fmt.Sprintf("Tax %.2f%%", rate) +} + +func conversionRateLabel(rate float64, code string) string { + code = strings.TrimSpace(code) + if code == "" { + return fmt.Sprintf("Rate %.6f", rate) + } + 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 } diff --git a/templates/layout_templ.go b/templates/layout_templ.go index 71d1596..b955d1f 100644 --- a/templates/layout_templ.go +++ b/templates/layout_templ.go @@ -10,7 +10,7 @@ import templruntime "github.com/a-h/templ/runtime" import pscatalog "git.ma-al.com/goc_marek/ps_shop/internal/prestashop/catalog" -func Layout(title string, cssPath string, jsPath string, menu []pscatalog.MenuItem, locale pscatalog.HeaderLocaleData) templ.Component { +func Layout(title string, cssPath string, jsPath string, menu []pscatalog.MenuItem, locale pscatalog.HeaderLocaleData, cartItems int64) 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 { @@ -131,7 +131,43 @@ func Layout(title string, cssPath string, jsPath string, menu []pscatalog.MenuIt return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
🛒
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
🛒 ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if cartItems > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(cartItems) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 63, Col: 205} + } + _, 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, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -139,7 +175,7 @@ func Layout(title string, cssPath string, jsPath string, menu []pscatalog.MenuIt if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -163,247 +199,247 @@ func LocalePicker(title string, current pscatalog.LocaleOption, options []pscata }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var6 := templ.GetChildren(ctx) - if templ_7745c5c3_Var6 == nil { - templ_7745c5c3_Var6 = templ.NopComponent + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if current.Label != "" { - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(current.Label) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 75, Col: 20} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 77, Col: 12} - } - _, 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, 15, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if showMeta && current.Code != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(current.Code) + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(current.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 81, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 80, Col: 20} } _, 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, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if len(options) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } + } else { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 87, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 82, Col: 12} } _, 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, "

    ") + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if showMeta && current.Code != "" { + 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(current.Code) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 86, Col: 52} + } + _, 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, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(options) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 92, Col: 43} + } + _, 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, 24, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -427,62 +463,40 @@ func MenuTree(items []pscatalog.MenuItem, depth int) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var21 := templ.GetChildren(ctx) - if templ_7745c5c3_Var21 == nil { - templ_7745c5c3_Var21 = templ.NopComponent + templ_7745c5c3_Var23 := templ.GetChildren(ctx) + if templ_7745c5c3_Var23 == nil { + templ_7745c5c3_Var23 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var22 = []any{menuListClass(depth)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var22...) + var templ_7745c5c3_Var24 = []any{menuListClass(depth)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -570,149 +606,97 @@ func MegaMenuBar(items []pscatalog.MenuItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var30 := templ.GetChildren(ctx) - if templ_7745c5c3_Var30 == nil { - templ_7745c5c3_Var30 = templ.NopComponent + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -818,151 +854,151 @@ func MegaMenu(id int64, href string, label string, columns []pscatalog.MenuItem) }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var47 := templ.GetChildren(ctx) - if templ_7745c5c3_Var47 == nil { - templ_7745c5c3_Var47 = templ.NopComponent + templ_7745c5c3_Var49 := templ.GetChildren(ctx) + if templ_7745c5c3_Var49 == nil { + templ_7745c5c3_Var49 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" data-mega-menu>
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, column := range columns { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(column.Name) + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(column.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 175, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 180, Col: 68} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(column.Children) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "View all") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\">View all") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/product.templ b/templates/product.templ index a380aff..79bc3ae 100644 --- a/templates/product.templ +++ b/templates/product.templ @@ -7,9 +7,9 @@ import ( ) 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)) {
-
+
Prestashop Proxy
@@ -24,55 +24,245 @@ templ ProductPage(data viewmodel.ProductPageData, cssPath string, jsPath string)
-
+

Product

+ if data.Product.ImageURL != "" { + + } + if len(data.Product.GalleryImages) > 1 { +
+
+ +
+
+ for i, image := range data.Product.GalleryImages { + if image.ThumbURL != "" && image.URL != "" { + + } + } +
+
+ +
+
+ } if data.CategoryURL != "" && data.Product.CategoryName != "" { { data.Product.CategoryName } }

{ data.Product.Name }

-

{ data.Product.ShortDescription }

-
- @templ.Raw(data.Product.Description) -
- if data.Session != nil { -
-

Go Cookie Debug

-
-
-

Raw Cookie

-
{ data.Session.RawCookie }
-
-
-

Decoded Values

-
-									for _, line := range sessionCookieLines(data.Session) {
-										{ line }
-										{"\n"}
-									}
-								
-
+ if data.Product.ShortDescription != "" { +
+
+

Summary

+

At a glance

+
+

{ plainTextHTML(data.Product.ShortDescription) }

+
+ } + + if data.Product.Description != "" { +
+
+

Description

+

About this product

+
+
+ @templ.Raw(data.Product.Description)
} + + if len(data.Product.Features) > 0 { +
+
+

Features

+

Product details

+
+
+ for _, feature := range data.Product.Features { +
+

{ feature.Name }

+

{ plainTextHTML(feature.Value) }

+
+ } +
+
+ } + if len(data.Product.Accessories) > 0 { +
+
+

Related products

+

Accessories

+
+
+ for _, product := range data.Product.Accessories { +
+ if product.ImageURL != "" { + + { + + } +

Accessory

+

{ product.Name }

+

{ truncatedPlainTextHTML(product.ShortDescription, 180) }

+
+
+

{ moneyWithCurrency(product.PriceTaxIncl, product.CurrencySign, product.CurrencyCode) }

+

{ taxLabel(product.TaxRate) } · { conversionRateLabel(product.ConversionRate, product.CurrencyCode) }

+
+ + View Product + +
+
+ } +
+
+ } + if len(data.Product.GalleryImages) > 0 { + + }
} diff --git a/templates/product_templ.go b/templates/product_templ.go index 4d21ef6..51d5862 100644 --- a/templates/product_templ.go +++ b/templates/product_templ.go @@ -47,7 +47,7 @@ func ProductPage(data viewmodel.ProductPageData, cssPath string, jsPath string) }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Product

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if data.CategoryURL != "" && data.Product.CategoryName != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" alt=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.Product.CategoryName) + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.Product.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 31, Col: 168} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 32, Col: 118} } _, 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, 11, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-product-main-image=\"\" data-default-image=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Product.ImageURL) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 32, Col: 190} + } + _, 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, 12, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Product.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 33, Col: 76} - } - _, 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, 13, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.Product.ShortDescription) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 34, Col: 96} - } - _, 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, 14, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templ.Raw(data.Product.Description).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if data.Session != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

Go Cookie Debug

Raw Cookie

")
+			if len(data.Product.GalleryImages) > 1 {
+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(data.Session.RawCookie) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 62, Col: 127} - } - _, 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, 21, "

Decoded Values

")
-				if templ_7745c5c3_Err != nil {
-					return templ_7745c5c3_Err
-				}
-				for _, line := range sessionCookieLines(data.Session) {
-					var templ_7745c5c3_Var15 string
-					templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(line)
-					if templ_7745c5c3_Err != nil {
-						return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 68, Col: 16}
+				for i, image := range data.Product.GalleryImages {
+					if image.ThumbURL != "" && image.URL != "" {
+						templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "")
+						if templ_7745c5c3_Err != nil {
+							return templ_7745c5c3_Err
+						}
 					}
-					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+				}
+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.CategoryURL != "" && data.Product.CategoryName != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(data.Product.CategoryName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 59, Col: 168} + } + _, 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, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(data.Product.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 61, Col: 76} + } + _, 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, 26, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " Account and login remain on PrestaShop") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Product.ShortDescription != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "

Summary

At a glance

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var56 string + templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(plainTextHTML(data.Product.ShortDescription)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 154, Col: 112} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Product.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "

Description

About this product

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(data.Product.Description).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(data.Product.Features) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

Features

Product details

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, feature := range data.Product.Features { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var57 string + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(feature.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 179, Col: 91} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var58 string + templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(plainTextHTML(feature.Value)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 180, Col: 88} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(data.Product.Accessories) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "

Related products

Accessories

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, product := range data.Product.Accessories { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if product.ImageURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\"")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "

Accessory

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var62 string + templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(product.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 201, Col: 77} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var63 string + templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(truncatedPlainTextHTML(product.ShortDescription, 180)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 202, Col: 113} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var64 string + templ_7745c5c3_Var64, 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/product.templ`, Line: 205, Col: 144} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var65 string + templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(taxLabel(product.TaxRate)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 206, Col: 104} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var66 string + templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(conversionRateLabel(product.ConversionRate, product.CurrencyCode)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/product.templ`, Line: 206, Col: 177} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

View Product
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(data.Product.GalleryImages) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = Layout(data.Product.Name, cssPath, jsPath, data.Menu, data.Locale).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Layout(data.Product.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 { return templ_7745c5c3_Err } diff --git a/web/dist/app.css b/web/dist/app.css index d94df41..786169e 100644 --- a/web/dist/app.css +++ b/web/dist/app.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--cta-glow:0 0 0 0 transparent}body{font-family:IBM Plex Sans,Avenir Next,Segoe UI,sans-serif;background:radial-gradient(circle at top left,hsla(39,82%,69%,.24),transparent 28%),radial-gradient(circle at top right,rgba(157,217,210,.16),transparent 34%),linear-gradient(180deg,#fbfaf6,#f5efe3 55%,#f7f3ea);--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1))}h1,h2,h3{font-family:Cormorant Garamond,IBM Plex Sans,serif}.site-container{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding-left:1rem;padding-right:1rem}@media (min-width:640px){.site-container{padding-left:1.25rem;padding-right:1.25rem}}@media (min-width:1024px){.site-container{padding-left:2rem;padding-right:2rem}}.site-header{position:sticky;top:0;z-index:40;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.utility-bar{background:linear-gradient(90deg,rgba(20,33,61,.98),rgba(37,58,89,.94));border-bottom-width:1px;border-color:hsla(0,0%,100%,.1)}.header-locale,.utility-bar{--tw-text-opacity:1;color:rgb(245 245 244/var(--tw-text-opacity,1))}.header-locale{display:flex;width:100%;flex-wrap:wrap;align-items:center;justify-content:flex-start;gap:.5rem;font-size:.875rem;line-height:1.25rem}@media (min-width:640px){.header-locale{width:auto;justify-content:flex-end}}.locale-picker{position:relative;width:100%}@media (min-width:640px){.locale-picker{width:auto}}.locale-picker__summary{display:flex;width:100%;cursor:pointer;list-style-type:none;align-items:center;justify-content:space-between;gap:.5rem;border-radius:9999px;border-width:1px;border-color:hsla(0,0%,100%,0);padding:.375rem .75rem;--tw-text-opacity:1;color:rgb(245 245 244/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.locale-picker__summary:hover{border-color:hsla(0,0%,100%,.2);background-color:hsla(0,0%,100%,.1);--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}@media (min-width:640px){.locale-picker__summary{width:auto;justify-content:flex-start}}.locale-picker[open] .locale-picker__summary{border-color:hsla(0,0%,100%,.2);background-color:hsla(0,0%,100%,.1);--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.locale-picker__summary::-webkit-details-marker{display:none}.locale-picker__value{font-weight:500}.locale-picker__chevron,.locale-picker__code,.locale-picker__item-meta{font-size:.74rem;text-transform:uppercase;letter-spacing:.16em;color:hsla(24,6%,83%,.8)}.locale-picker__panel{left:0;right:0;z-index:50;margin-top:.75rem;min-width:13rem;border-radius:1.5rem;border-width:1px;border-color:hsla(0,0%,100%,.7);background-color:hsla(0,0%,100%,.95);padding:.75rem;--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1));--tw-shadow:0 22px 48px rgba(20,33,61,.18);--tw-shadow-colored:0 22px 48px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:640px){.locale-picker__panel{position:absolute;left:auto;right:0}}.locale-picker__title{margin-bottom:.5rem;padding-left:.5rem;padding-right:.5rem;font-size:.68rem;font-weight:600;text-transform:uppercase;letter-spacing:.2em;--tw-text-opacity:1;color:rgb(168 162 158/var(--tw-text-opacity,1))}.main-nav{background:rgba(255,251,245,.82);border-bottom-width:1px;border-color:hsla(0,0%,100%,.6);--tw-shadow:0 10px 30px rgba(20,33,61,.08);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.header-bar{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding-top:1rem;padding-bottom:1rem}@media (min-width:640px){.header-bar{gap:1.25rem;padding-top:1.25rem;padding-bottom:1.25rem}}@media (min-width:1024px){.header-bar{flex-wrap:nowrap}}.brand-mark{display:inline-flex;align-items:flex-end;gap:.125rem;font-size:2.25rem;line-height:2.5rem;font-weight:900;line-height:1;letter-spacing:-.025em;--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1));transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.3s}.brand-mark:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.brand-mark{font-size:3rem;line-height:1}}.brand-mark__accent{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.menu-toggle{display:inline-flex;height:2.75rem;align-items:center;justify-content:center;border-radius:9999px;border-width:1px;border-color:hsla(24,6%,83%,.7);background-color:hsla(0,0%,100%,.8);padding-left:1.25rem;padding-right:1.25rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;letter-spacing:.28em;--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.menu-toggle:hover{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1));--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.menu-panel{order:9999;width:100%;border-radius:1.5rem;border-width:1px;border-color:hsla(0,0%,100%,.7);background-color:hsla(0,0%,100%,.95);padding:1rem;--tw-shadow:0 16px 34px rgba(20,33,61,.12);--tw-shadow-colored:0 16px 34px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.menu-panel{order:0;border-radius:0;border-width:0;background-color:transparent;padding:0;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}}.desktop-nav{display:flex;align-items:center;justify-content:center;gap:2rem}@media (min-width:1280px){.desktop-nav{gap:3rem}}.desktop-nav__toggle{display:inline-flex;height:2rem;width:1.25rem;align-items:center;justify-content:center;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(168 162 158/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.desktop-nav__entry--open .desktop-nav__link,.desktop-nav__entry--open .desktop-nav__toggle,.desktop-nav__toggle:hover{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.mega-menu{background:linear-gradient(180deg,rgba(255,252,247,.98),hsla(40,67%,96%,.98));position:absolute;left:0;right:0;top:100%;z-index:50;border-top-width:1px;border-color:hsla(0,0%,100%,.7);--tw-shadow:0 28px 60px rgba(20,33,61,.16);--tw-shadow-colored:0 28px 60px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.mega-menu__grid{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding-left:1rem;padding-right:1rem}@media (min-width:640px){.mega-menu__grid{padding-left:1.25rem;padding-right:1.25rem}}@media (min-width:1024px){.mega-menu__grid{padding-left:2rem;padding-right:2rem}}.mega-menu__grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));-moz-column-gap:3rem;column-gap:3rem;row-gap:2.5rem;padding-top:2.5rem;padding-bottom:2.5rem}@media (min-width:1024px){.mega-menu__grid{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1280px){.mega-menu__grid{grid-template-columns:repeat(5,minmax(0,1fr))}}.mega-menu__heading{margin-bottom:1rem;display:inline-flex;font-size:.875rem;line-height:1.25rem;font-weight:600;text-transform:uppercase;letter-spacing:.12em;--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mega-menu__heading:hover{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.mega-menu__link{display:inline-flex;font-size:1.02rem;line-height:1.75rem;--tw-text-opacity:1;color:rgb(68 64 60/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mega-menu__link:hover{--tw-translate-x:0.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.mega-menu__all{grid-column:1/-1;display:flex;align-items:flex-start;justify-content:flex-end;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(231 229 228/var(--tw-border-opacity,1));padding-top:1.25rem}.mega-menu__all-link{display:inline-flex;font-size:.875rem;line-height:1.25rem;font-weight:600;text-transform:uppercase;letter-spacing:.18em;--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mega-menu__all-link:hover{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.header-actions{margin-left:0;display:flex;align-items:center;gap:1rem;font-size:1.5rem;line-height:2rem;--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1))}@media (min-width:1024px){.header-actions{margin-left:auto}}.nav-icon{display:flex;height:2.75rem;width:2.75rem;align-items:center;justify-content:center;border-radius:9999px;background-color:hsla(0,0%,100%,.7);font-size:1.35rem;--tw-shadow:0 10px 24px rgba(20,33,61,.08);--tw-shadow-colored:0 10px 24px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.nav-icon:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}@media (max-width:1023px){.desktop-nav,.mega-menu{display:none}}button[type=submit]{box-shadow:var(--cta-glow)}.mx-auto{margin-right:auto}.ml-auto,.mx-auto{margin-left:auto}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.min-h-screen{min-height:100vh}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-none{max-width:none}.shrink-0{flex-shrink:0}.basis-full{flex-basis:100%}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-10{gap:2.5rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-2{row-gap:.5rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-\[1\.75rem\]{border-radius:1.75rem}.rounded-\[2rem\]{border-radius:2rem}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-amber-500\/30{border-color:rgba(245,158,11,.3)}.border-emerald-400\/40{border-color:rgba(52,211,153,.4)}.border-emerald-500\/20{border-color:rgba(16,185,129,.2)}.border-stone-800{--tw-border-opacity:1;border-color:rgb(41 37 36/var(--tw-border-opacity,1))}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.bg-amber-300{--tw-bg-opacity:1;background-color:rgb(252 211 77/var(--tw-bg-opacity,1))}.bg-amber-400\/10{background-color:rgba(251,191,36,.1)}.bg-black\/30{background-color:rgba(0,0,0,.3)}.bg-slate-900\/70{background-color:rgba(15,23,42,.7)}.bg-slate-950\/70{background-color:rgba(2,6,23,.7)}.bg-stone-900\/70{background-color:rgba(28,25,23,.7)}.bg-stone-950\/80{background-color:rgba(12,10,9,.8)}.bg-white\/5{background-color:hsla(0,0%,100%,.05)}.bg-\[radial-gradient\(circle_at_top\2c _rgba\(245\2c 158\2c 11\2c 0\.28\)\2c _transparent_40\%\)\2c linear-gradient\(180deg\2c \#0c0a09\2c \#1c1917\)\]{background-image:radial-gradient(circle at top,rgba(245,158,11,.28),transparent 40%),linear-gradient(180deg,#0c0a09,#1c1917)}.bg-\[radial-gradient\(circle_at_top_left\2c _rgba\(34\2c 197\2c 94\2c 0\.18\)\2c _transparent_35\%\)\2c linear-gradient\(180deg\2c \#0b1020\2c \#111827\)\]{background-image:radial-gradient(circle at top left,rgba(34,197,94,.18),transparent 35%),linear-gradient(180deg,#0b1020,#111827)}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.pb-6{padding-bottom:1.5rem}.text-right{text-align:right}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.92rem\]{font-size:.92rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-6{line-height:1.5rem}.leading-7{line-height:1.75rem}.leading-8{line-height:2rem}.tracking-\[0\.22em\]{letter-spacing:.22em}.tracking-\[0\.24em\]{letter-spacing:.24em}.tracking-\[0\.28em\]{letter-spacing:.28em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-\[0\.32em\]{letter-spacing:.32em}.text-amber-200{--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.text-amber-300\/80{color:rgba(252,211,77,.8)}.text-emerald-200{--tw-text-opacity:1;color:rgb(167 243 208/var(--tw-text-opacity,1))}.text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-stone-200{--tw-text-opacity:1;color:rgb(231 229 228/var(--tw-text-opacity,1))}.text-stone-300{--tw-text-opacity:1;color:rgb(214 211 209/var(--tw-text-opacity,1))}.text-stone-400{--tw-text-opacity:1;color:rgb(168 162 158/var(--tw-text-opacity,1))}.text-stone-50{--tw-text-opacity:1;color:rgb(250 250 249/var(--tw-text-opacity,1))}.text-stone-500{--tw-text-opacity:1;color:rgb(120 113 108/var(--tw-text-opacity,1))}.text-stone-700{--tw-text-opacity:1;color:rgb(68 64 60/var(--tw-text-opacity,1))}.text-stone-900{--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1))}.text-stone-950{--tw-text-opacity:1;color:rgb(12 10 9/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-\[0_24px_80px_rgba\(0\2c 0\2c 0\2c 0\.35\)\]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_24px_80px_rgba\(0\2c 0\2c 0\2c 0\.35\)\]{--tw-shadow:0 24px 80px rgba(0,0,0,.35);--tw-shadow-colored:0 24px 80px var(--tw-shadow-color)}.shadow-amber-950\/20{--tw-shadow-color:rgba(69,26,3,.2);--tw-shadow:var(--tw-shadow-colored)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-emerald-400\/40:hover{border-color:rgba(52,211,153,.4)}.hover\:bg-amber-200:hover{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.hover\:bg-emerald-300:hover{--tw-bg-opacity:1;background-color:rgb(110 231 183/var(--tw-bg-opacity,1))}.hover\:text-amber-600:hover{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.hover\:text-slate-950:hover{--tw-text-opacity:1;color:rgb(2 6 23/var(--tw-text-opacity,1))}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.lg\:ml-10{margin-left:2.5rem}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:min-w-0{min-width:0}.lg\:flex-1{flex:1 1 0%}.lg\:basis-auto{flex-basis:auto}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-\[1\.1fr_0\.9fr\]{grid-template-columns:1.1fr .9fr}.lg\:items-center{align-items:center}.lg\:justify-center{justify-content:center}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--cta-glow:0 0 0 0 transparent}body{font-family:IBM Plex Sans,Avenir Next,Segoe UI,sans-serif;background:radial-gradient(circle at top left,hsla(39,82%,69%,.24),transparent 28%),radial-gradient(circle at top right,rgba(157,217,210,.16),transparent 34%),linear-gradient(180deg,#fbfaf6,#f5efe3 55%,#f7f3ea);--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1))}h1,h2,h3{font-family:Cormorant Garamond,IBM Plex Sans,serif}.site-container{margin-left:auto;margin-right:auto;width:100%;max-width:104rem;padding-left:1rem;padding-right:1rem}@media (min-width:640px){.site-container{padding-left:1.25rem;padding-right:1.25rem}}@media (min-width:1024px){.site-container{padding-left:2rem;padding-right:2rem}}.site-header{position:sticky;top:0;z-index:40;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.utility-bar{background:linear-gradient(90deg,rgba(20,33,61,.98),rgba(37,58,89,.94));border-bottom-width:1px;border-color:hsla(0,0%,100%,.1)}.header-locale,.utility-bar{--tw-text-opacity:1;color:rgb(245 245 244/var(--tw-text-opacity,1))}.header-locale{display:flex;width:100%;flex-wrap:wrap;align-items:center;justify-content:flex-start;gap:.5rem;font-size:.875rem;line-height:1.25rem}@media (min-width:640px){.header-locale{width:auto;justify-content:flex-end}}.locale-picker{position:relative;width:100%}@media (min-width:640px){.locale-picker{width:auto}}.locale-picker__summary{display:flex;width:100%;cursor:pointer;list-style-type:none;align-items:center;justify-content:space-between;gap:.5rem;border-radius:9999px;border-width:1px;border-color:hsla(0,0%,100%,0);padding:.375rem .75rem;--tw-text-opacity:1;color:rgb(245 245 244/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.locale-picker__summary:hover{border-color:hsla(0,0%,100%,.2);background-color:hsla(0,0%,100%,.1);--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}@media (min-width:640px){.locale-picker__summary{width:auto;justify-content:flex-start}}.locale-picker[open] .locale-picker__summary{border-color:hsla(0,0%,100%,.2);background-color:hsla(0,0%,100%,.1);--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.locale-picker__summary::-webkit-details-marker{display:none}.locale-picker__value{font-weight:500}.locale-picker__chevron,.locale-picker__code,.locale-picker__item-meta{font-size:.74rem;text-transform:uppercase;letter-spacing:.16em;color:hsla(24,6%,83%,.8)}.locale-picker__panel{left:0;right:0;z-index:50;margin-top:.75rem;min-width:13rem;border-radius:1.5rem;border-width:1px;border-color:hsla(0,0%,100%,.7);background-color:hsla(0,0%,100%,.95);padding:.75rem;--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1));--tw-shadow:0 22px 48px rgba(20,33,61,.18);--tw-shadow-colored:0 22px 48px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:640px){.locale-picker__panel{position:absolute;left:auto;right:0}}.locale-picker__title{margin-bottom:.5rem;padding-left:.5rem;padding-right:.5rem;font-size:.68rem;font-weight:600;text-transform:uppercase;letter-spacing:.2em;--tw-text-opacity:1;color:rgb(168 162 158/var(--tw-text-opacity,1))}.main-nav{background:rgba(255,251,245,.82);border-bottom-width:1px;border-color:hsla(0,0%,100%,.6);--tw-shadow:0 10px 30px rgba(20,33,61,.08);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.header-bar{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding-top:1rem;padding-bottom:1rem}@media (min-width:640px){.header-bar{gap:1.25rem;padding-top:1.25rem;padding-bottom:1.25rem}}@media (min-width:1024px){.header-bar{flex-wrap:nowrap}}.brand-mark{display:inline-flex;align-items:flex-end;gap:.125rem;font-size:2.25rem;line-height:2.5rem;font-weight:900;line-height:1;letter-spacing:-.025em;--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1));transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.3s}.brand-mark:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.brand-mark{font-size:3rem;line-height:1}}.brand-mark__accent{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.menu-toggle{display:inline-flex;height:2.75rem;align-items:center;justify-content:center;border-radius:9999px;border-width:1px;border-color:hsla(24,6%,83%,.7);background-color:hsla(0,0%,100%,.8);padding-left:1.25rem;padding-right:1.25rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;letter-spacing:.28em;--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.menu-toggle:hover{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1));--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.menu-panel{order:9999;width:100%;border-radius:1.5rem;border-width:1px;border-color:hsla(0,0%,100%,.7);background-color:hsla(0,0%,100%,.95);padding:1rem;--tw-shadow:0 16px 34px rgba(20,33,61,.12);--tw-shadow-colored:0 16px 34px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.menu-panel{order:0;border-radius:0;border-width:0;background-color:transparent;padding:0;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}}.desktop-nav{display:flex;align-items:center;justify-content:center;gap:2rem}@media (min-width:1280px){.desktop-nav{gap:3rem}}.desktop-nav__toggle{display:inline-flex;height:2rem;width:1.25rem;align-items:center;justify-content:center;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(168 162 158/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.desktop-nav__entry--open .desktop-nav__link,.desktop-nav__entry--open .desktop-nav__toggle,.desktop-nav__toggle:hover{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.mega-menu{background:linear-gradient(180deg,rgba(255,252,247,.98),hsla(40,67%,96%,.98));position:absolute;left:0;right:0;top:100%;z-index:50;border-top-width:1px;border-color:hsla(0,0%,100%,.7);--tw-shadow:0 28px 60px rgba(20,33,61,.16);--tw-shadow-colored:0 28px 60px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.mega-menu__grid{margin-left:auto;margin-right:auto;width:100%;max-width:104rem;padding-left:1rem;padding-right:1rem}@media (min-width:640px){.mega-menu__grid{padding-left:1.25rem;padding-right:1.25rem}}@media (min-width:1024px){.mega-menu__grid{padding-left:2rem;padding-right:2rem}}.mega-menu__grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));-moz-column-gap:3rem;column-gap:3rem;row-gap:2.5rem;padding-top:2.5rem;padding-bottom:2.5rem}@media (min-width:1024px){.mega-menu__grid{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1280px){.mega-menu__grid{grid-template-columns:repeat(5,minmax(0,1fr))}}.mega-menu__heading{margin-bottom:1rem;display:inline-flex;font-size:.875rem;line-height:1.25rem;font-weight:600;text-transform:uppercase;letter-spacing:.12em;--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mega-menu__heading:hover{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.mega-menu__link{display:inline-flex;font-size:1.02rem;line-height:1.75rem;--tw-text-opacity:1;color:rgb(68 64 60/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mega-menu__link:hover{--tw-translate-x:0.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.mega-menu__all{grid-column:1/-1;display:flex;align-items:flex-start;justify-content:flex-end;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(231 229 228/var(--tw-border-opacity,1));padding-top:1.25rem}.mega-menu__all-link{display:inline-flex;font-size:.875rem;line-height:1.25rem;font-weight:600;text-transform:uppercase;letter-spacing:.18em;--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mega-menu__all-link:hover{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.header-actions{margin-left:0;display:flex;align-items:center;gap:1rem;font-size:1.5rem;line-height:2rem;--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1))}@media (min-width:1024px){.header-actions{margin-left:auto}}.nav-icon{display:flex;height:2.75rem;width:2.75rem;align-items:center;justify-content:center;border-radius:9999px;background-color:hsla(0,0%,100%,.7);font-size:1.35rem;--tw-shadow:0 10px 24px rgba(20,33,61,.08);--tw-shadow-colored:0 10px 24px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.nav-icon:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}@media (max-width:1023px){.desktop-nav,.mega-menu{display:none}}button[type=submit]{box-shadow:var(--cta-glow)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.inset-x-0{left:0;right:0}.-right-14{right:-3.5rem}.-right-2{right:-.5rem}.-top-2{top:-.5rem}.bottom-\[-4rem\]{bottom:-4rem}.left-0{left:0}.left-4{left:1rem}.left-\[-2rem\]{left:-2rem}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-1\/2{top:50%}.top-\[-3\.5rem\]{top:-3.5rem}.top-full{top:100%}.z-20{z-index:20}.z-\[70\]{z-index:70}.mx-auto{margin-left:auto;margin-right:auto}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.h-11{height:2.75rem}.h-20{height:5rem}.h-32{height:8rem}.h-40{height:10rem}.h-44{height:11rem}.h-56{height:14rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-72{height:18rem}.h-80{height:20rem}.h-full{height:100%}.max-h-full{max-height:100%}.min-h-0{min-height:0}.min-h-5{min-height:1.25rem}.min-h-screen{min-height:100vh}.w-11{width:2.75rem}.w-20{width:5rem}.w-24{width:6rem}.w-40{width:10rem}.w-44{width:11rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-5{min-width:1.25rem}.max-w-2xl{max-width:42rem}.max-w-\[104rem\]{max-width:104rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.basis-full{flex-basis:100%}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.grid-cols-\[minmax\(0\2c 1fr\)_11rem\]{grid-template-columns:minmax(0,1fr) 11rem}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-10{gap:2.5rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-2{row-gap:.5rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis;white-space:nowrap}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-\[1\.1rem\]{border-radius:1.1rem}.rounded-\[1\.25rem\]{border-radius:1.25rem}.rounded-\[1\.35rem\]{border-radius:1.35rem}.rounded-\[1\.4rem\]{border-radius:1.4rem}.rounded-\[1\.5rem\]{border-radius:1.5rem}.rounded-\[1\.6rem\]{border-radius:1.6rem}.rounded-\[1\.75rem\]{border-radius:1.75rem}.rounded-\[1\.7rem\]{border-radius:1.7rem}.rounded-\[2\.2rem\]{border-radius:2.2rem}.rounded-\[2rem\]{border-radius:2rem}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-300\/20{border-color:rgba(252,211,77,.2)}.border-amber-300\/40{border-color:rgba(252,211,77,.4)}.border-amber-400\/20{border-color:rgba(251,191,36,.2)}.border-amber-400\/30{border-color:rgba(251,191,36,.3)}.border-amber-400\/40{border-color:rgba(251,191,36,.4)}.border-amber-500\/20{border-color:rgba(245,158,11,.2)}.border-amber-500\/30{border-color:rgba(245,158,11,.3)}.border-emerald-400\/40{border-color:rgba(52,211,153,.4)}.border-emerald-500\/20{border-color:rgba(16,185,129,.2)}.border-stone-200{--tw-border-opacity:1;border-color:rgb(231 229 228/var(--tw-border-opacity,1))}.border-stone-300{--tw-border-opacity:1;border-color:rgb(214 211 209/var(--tw-border-opacity,1))}.border-stone-700{--tw-border-opacity:1;border-color:rgb(68 64 60/var(--tw-border-opacity,1))}.border-stone-800{--tw-border-opacity:1;border-color:rgb(41 37 36/var(--tw-border-opacity,1))}.border-stone-900\/80{border-color:rgba(28,25,23,.8)}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.bg-amber-300{--tw-bg-opacity:1;background-color:rgb(252 211 77/var(--tw-bg-opacity,1))}.bg-amber-300\/10{background-color:rgba(252,211,77,.1)}.bg-amber-400\/10{background-color:rgba(251,191,36,.1)}.bg-black\/15{background-color:rgba(0,0,0,.15)}.bg-black\/20{background-color:rgba(0,0,0,.2)}.bg-black\/80{background-color:rgba(0,0,0,.8)}.bg-orange-500\/10{background-color:rgba(249,115,22,.1)}.bg-slate-900\/70{background-color:rgba(15,23,42,.7)}.bg-slate-950\/50{background-color:rgba(2,6,23,.5)}.bg-slate-950\/70{background-color:rgba(2,6,23,.7)}.bg-stone-100{--tw-bg-opacity:1;background-color:rgb(245 245 244/var(--tw-bg-opacity,1))}.bg-stone-50{--tw-bg-opacity:1;background-color:rgb(250 250 249/var(--tw-bg-opacity,1))}.bg-stone-900\/70{background-color:rgba(28,25,23,.7)}.bg-stone-950{--tw-bg-opacity:1;background-color:rgb(12 10 9/var(--tw-bg-opacity,1))}.bg-stone-950\/55{background-color:rgba(12,10,9,.55)}.bg-stone-950\/60{background-color:rgba(12,10,9,.6)}.bg-stone-950\/70{background-color:rgba(12,10,9,.7)}.bg-stone-950\/75{background-color:rgba(12,10,9,.75)}.bg-stone-950\/80{background-color:rgba(12,10,9,.8)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/5{background-color:hsla(0,0%,100%,.05)}.bg-\[linear-gradient\(135deg\2c rgba\(28\2c 25\2c 23\2c 0\.96\)\2c rgba\(120\2c 53\2c 15\2c 0\.34\)\)\]{background-image:linear-gradient(135deg,rgba(28,25,23,.96),rgba(120,53,15,.34))}.bg-\[linear-gradient\(145deg\2c rgba\(251\2c 191\2c 36\2c 0\.12\)\2c transparent_58\%\)\]{background-image:linear-gradient(145deg,rgba(251,191,36,.12),transparent 58%)}.bg-\[linear-gradient\(180deg\2c rgba\(245\2c 158\2c 11\2c 0\.08\)\2c rgba\(28\2c 25\2c 23\2c 0\.32\)\)\]{background-image:linear-gradient(180deg,rgba(245,158,11,.08),rgba(28,25,23,.32))}.bg-\[linear-gradient\(180deg\2c rgba\(245\2c 158\2c 11\2c 0\.12\)\2c rgba\(28\2c 25\2c 23\2c 0\.92\)_34\%\2c rgba\(28\2c 25\2c 23\2c 0\.98\)\)\]{background-image:linear-gradient(180deg,rgba(245,158,11,.12),rgba(28,25,23,.92) 34%,rgba(28,25,23,.98))}.bg-\[linear-gradient\(180deg\2c rgba\(28\2c 25\2c 23\2c 0\.98\)\2c rgba\(12\2c 10\2c 9\2c 0\.98\)\)\]{background-image:linear-gradient(180deg,rgba(28,25,23,.98),rgba(12,10,9,.98))}.bg-\[linear-gradient\(180deg\2c rgba\(41\2c 37\2c 36\2c 0\.88\)\2c rgba\(28\2c 25\2c 23\2c 0\.96\)\)\]{background-image:linear-gradient(180deg,rgba(41,37,36,.88),rgba(28,25,23,.96))}.bg-\[radial-gradient\(circle_at_top\2c _rgba\(245\2c 158\2c 11\2c 0\.22\)\2c _transparent_34\%\)\2c radial-gradient\(circle_at_bottom_left\2c _rgba\(120\2c 53\2c 15\2c 0\.22\)\2c _transparent_36\%\)\2c linear-gradient\(180deg\2c \#0c0a09\2c \#1c1917\)\]{background-image:radial-gradient(circle at top,rgba(245,158,11,.22),transparent 34%),radial-gradient(circle at bottom left,rgba(120,53,15,.22),transparent 36%),linear-gradient(180deg,#0c0a09,#1c1917)}.bg-\[radial-gradient\(circle_at_top\2c _rgba\(245\2c 158\2c 11\2c 0\.28\)\2c _transparent_40\%\)\2c linear-gradient\(180deg\2c \#0c0a09\2c \#1c1917\)\]{background-image:radial-gradient(circle at top,rgba(245,158,11,.28),transparent 40%),linear-gradient(180deg,#0c0a09,#1c1917)}.bg-\[radial-gradient\(circle_at_top_left\2c _rgba\(34\2c 197\2c 94\2c 0\.18\)\2c _transparent_35\%\)\2c linear-gradient\(180deg\2c \#0b1020\2c \#111827\)\]{background-image:radial-gradient(circle at top left,rgba(34,197,94,.18),transparent 35%),linear-gradient(180deg,#0b1020,#111827)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-12{padding:3rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-0{padding-left:0;padding-right:0}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-4{padding-bottom:1rem}.pb-5{padding-bottom:1.25rem}.pb-6{padding-bottom:1.5rem}.pt-5{padding-top:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.68rem\]{font-size:.68rem}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.75rem\]{font-size:.75rem}.text-\[0\.7rem\]{font-size:.7rem}.text-\[0\.92rem\]{font-size:.92rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-7{line-height:1.75rem}.leading-8{line-height:2rem}.leading-none{line-height:1}.tracking-\[0\.16em\]{letter-spacing:.16em}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.22em\]{letter-spacing:.22em}.tracking-\[0\.24em\]{letter-spacing:.24em}.tracking-\[0\.26em\]{letter-spacing:.26em}.tracking-\[0\.28em\]{letter-spacing:.28em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-\[0\.32em\]{letter-spacing:.32em}.tracking-\[0\.38em\]{letter-spacing:.38em}.tracking-\[0\.3em\]{letter-spacing:.3em}.text-amber-100{--tw-text-opacity:1;color:rgb(254 243 199/var(--tw-text-opacity,1))}.text-amber-100\/80{color:hsla(48,96%,89%,.8)}.text-amber-200{--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.text-amber-300\/80{color:rgba(252,211,77,.8)}.text-emerald-200{--tw-text-opacity:1;color:rgb(167 243 208/var(--tw-text-opacity,1))}.text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-stone-100{--tw-text-opacity:1;color:rgb(245 245 244/var(--tw-text-opacity,1))}.text-stone-200{--tw-text-opacity:1;color:rgb(231 229 228/var(--tw-text-opacity,1))}.text-stone-300{--tw-text-opacity:1;color:rgb(214 211 209/var(--tw-text-opacity,1))}.text-stone-400{--tw-text-opacity:1;color:rgb(168 162 158/var(--tw-text-opacity,1))}.text-stone-50{--tw-text-opacity:1;color:rgb(250 250 249/var(--tw-text-opacity,1))}.text-stone-500{--tw-text-opacity:1;color:rgb(120 113 108/var(--tw-text-opacity,1))}.text-stone-700{--tw-text-opacity:1;color:rgb(68 64 60/var(--tw-text-opacity,1))}.text-stone-900{--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1))}.text-stone-950{--tw-text-opacity:1;color:rgb(12 10 9/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-\[0_16px_40px_rgba\(0\2c 0\2c 0\2c 0\.24\)\]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_16px_40px_rgba\(0\2c 0\2c 0\2c 0\.24\)\]{--tw-shadow:0 16px 40px rgba(0,0,0,.24);--tw-shadow-colored:0 16px 40px var(--tw-shadow-color)}.shadow-\[0_18px_40px_rgba\(0\2c 0\2c 0\2c 0\.12\)\]{--tw-shadow:0 18px 40px rgba(0,0,0,.12);--tw-shadow-colored:0 18px 40px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_24px_60px_rgba\(0\2c 0\2c 0\2c 0\.18\)\]{--tw-shadow:0 24px 60px rgba(0,0,0,.18);--tw-shadow-colored:0 24px 60px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_24px_60px_rgba\(0\2c 0\2c 0\2c 0\.28\)\]{--tw-shadow:0 24px 60px rgba(0,0,0,.28);--tw-shadow-colored:0 24px 60px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_24px_80px_rgba\(0\2c 0\2c 0\2c 0\.28\)\]{--tw-shadow:0 24px 80px rgba(0,0,0,.28);--tw-shadow-colored:0 24px 80px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_24px_80px_rgba\(0\2c 0\2c 0\2c 0\.35\)\]{--tw-shadow:0 24px 80px rgba(0,0,0,.35);--tw-shadow-colored:0 24px 80px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_24px_80px_rgba\(41\2c 24\2c 10\2c 0\.4\)\]{--tw-shadow:0 24px 80px rgba(41,24,10,.4);--tw-shadow-colored:0 24px 80px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_32px_120px_rgba\(0\2c 0\2c 0\2c 0\.55\)\]{--tw-shadow:0 32px 120px rgba(0,0,0,.55);--tw-shadow-colored:0 32px 120px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_32px_120px_rgba\(41\2c 24\2c 10\2c 0\.48\)\]{--tw-shadow:0 32px 120px rgba(41,24,10,.48);--tw-shadow-colored:0 32px 120px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_1px_0_rgba\(255\2c 255\2c 255\2c 0\.04\)\]{--tw-shadow:inset 0 1px 0 hsla(0,0%,100%,.04);--tw-shadow-colored:inset 0 1px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-amber-950\/10{--tw-shadow-color:rgba(69,26,3,.1);--tw-shadow:var(--tw-shadow-colored)}.shadow-amber-950\/20{--tw-shadow-color:rgba(69,26,3,.2);--tw-shadow:var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.ring-0{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur-3xl{--tw-blur:blur(64px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-blur-sm{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-amber-300\/40:hover{border-color:rgba(252,211,77,.4)}.hover\:border-amber-400\/30:hover{border-color:rgba(251,191,36,.3)}.hover\:border-amber-400\/40:hover{border-color:rgba(251,191,36,.4)}.hover\:border-emerald-400\/40:hover{border-color:rgba(52,211,153,.4)}.hover\:border-rose-300\/30:hover{border-color:rgba(253,164,175,.3)}.hover\:bg-amber-200:hover{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.hover\:bg-amber-300:hover{--tw-bg-opacity:1;background-color:rgb(252 211 77/var(--tw-bg-opacity,1))}.hover\:bg-emerald-300:hover{--tw-bg-opacity:1;background-color:rgb(110 231 183/var(--tw-bg-opacity,1))}.hover\:bg-rose-300:hover{--tw-bg-opacity:1;background-color:rgb(253 164 175/var(--tw-bg-opacity,1))}.hover\:bg-\[linear-gradient\(180deg\2c rgba\(120\2c 53\2c 15\2c 0\.22\)\2c rgba\(28\2c 25\2c 23\2c 0\.98\)\)\]:hover{background-image:linear-gradient(180deg,rgba(120,53,15,.22),rgba(28,25,23,.98))}.hover\:text-amber-200:hover{--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.hover\:text-amber-600:hover{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.hover\:text-slate-950:hover{--tw-text-opacity:1;color:rgb(2 6 23/var(--tw-text-opacity,1))}.hover\:text-stone-50:hover{--tw-text-opacity:1;color:rgb(250 250 249/var(--tw-text-opacity,1))}.hover\:text-stone-700:hover{--tw-text-opacity:1;color:rgb(68 64 60/var(--tw-text-opacity,1))}.hover\:text-stone-950:hover{--tw-text-opacity:1;color:rgb(12 10 9/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.group:hover .group-hover\:scale-\[1\.03\],.group\/thumb:hover .group-hover\/thumb\:scale-\[1\.03\]{--tw-scale-x:1.03;--tw-scale-y:1.03;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:text-amber-100{--tw-text-opacity:1;color:rgb(254 243 199/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:768px){.md\:grid{display:grid}.md\:h-\[28rem\]{height:28rem}.md\:w-32{width:8rem}.md\:min-w-\[11rem\]{min-width:11rem}.md\:shrink-0{flex-shrink:0}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-start{align-items:flex-start}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}.md\:p-6{padding:1.5rem}.md\:text-right{text-align:right}}@media (min-width:1024px){.lg\:ml-10{margin-left:2.5rem}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:h-24{height:6rem}.lg\:h-full{height:100%}.lg\:w-44{width:11rem}.lg\:w-full{width:100%}.lg\:min-w-0{min-width:0}.lg\:min-w-\[20rem\]{min-width:20rem}.lg\:flex-1{flex:1 1 0%}.lg\:basis-auto{flex-basis:auto}.lg\:grid-cols-\[1fr_1fr\]{grid-template-columns:1fr 1fr}.lg\:flex-row{flex-direction:row}.lg\:flex-col{flex-direction:column}.lg\:items-end{align-items:flex-end}.lg\:items-center{align-items:center}.lg\:justify-center{justify-content:center}.lg\:justify-between{justify-content:space-between}.lg\:p-10{padding:2.5rem}.lg\:p-6{padding:1.5rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:text-5xl{font-size:3rem;line-height:1}.lg\:text-base{font-size:1rem;line-height:1.5rem}}@media (min-width:1280px){.xl\:sticky{position:sticky}.xl\:top-8{top:2rem}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\2c 1\.55fr\)_24rem\]{grid-template-columns:minmax(0,1.55fr) 24rem}.xl\:self-start{align-self:flex-start}}@media (min-width:1536px){.\32xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file diff --git a/web/dist/app.js b/web/dist/app.js index adeb004..2a8ccea 100644 --- a/web/dist/app.js +++ b/web/dist/app.js @@ -1 +1 @@ -var l=document.documentElement;l.dataset.js="ready";var a=document.querySelector("[data-menu-toggle]"),d=document.querySelector("[data-menu-panel]"),r=[...document.querySelectorAll("[data-mega-trigger]")],c=[...document.querySelectorAll("[data-mega-menu]")];if(a&&d){let t=()=>{d.classList.add("hidden"),a.setAttribute("aria-expanded","false")},i=()=>{d.classList.remove("hidden"),a.setAttribute("aria-expanded","true")};a.addEventListener("click",()=>{if(a.getAttribute("aria-expanded")==="true"){t();return}i()}),window.addEventListener("resize",()=>{if(window.innerWidth>=1024){d.classList.remove("hidden"),a.setAttribute("aria-expanded","true");return}t()})}if(r.length>0&&c.length>0){let t=()=>{r.forEach((e)=>{e.setAttribute("aria-expanded","false"),e.closest(".desktop-nav__entry")?.classList.remove("desktop-nav__entry--open")}),c.forEach((e)=>{e.classList.add("hidden")})},i=(e)=>{let n=e.dataset.megaTarget;if(!n)return;t(),e.setAttribute("aria-expanded","true"),e.closest(".desktop-nav__entry")?.classList.add("desktop-nav__entry--open"),document.getElementById(n)?.classList.remove("hidden")};r.forEach((e)=>{e.addEventListener("click",(n)=>{if(window.innerWidth<1024)return;let s=e.getAttribute("aria-expanded")==="true";if(e instanceof HTMLAnchorElement&&s)return;if(n.preventDefault(),s){t();return}i(e)})}),document.addEventListener("click",(e)=>{if(window.innerWidth<1024)return;let n=e.target;if(!(n instanceof Node))return;let s=r.some((o)=>o.contains(n)),m=c.some((o)=>o.contains(n));if(!s&&!m)t()}),document.addEventListener("keydown",(e)=>{if(e.key==="Escape")t()}),window.addEventListener("resize",()=>{if(window.innerWidth<1024)t()})}var u=document.querySelector("button[type='submit']");if(u)u.addEventListener("mouseenter",()=>{l.style.setProperty("--cta-glow","0 0 0 4px rgba(252, 211, 77, 0.12)")}),u.addEventListener("mouseleave",()=>{l.style.setProperty("--cta-glow","0 0 0 0 rgba(0,0,0,0)")}); +var _=document.documentElement;_.dataset.js="ready";var L=document.querySelector("[data-menu-toggle]"),q=document.querySelector("[data-menu-panel]"),w=[...document.querySelectorAll("[data-mega-trigger]")],M=[...document.querySelectorAll("[data-mega-menu]")];if(L&&q){let d=()=>{q.classList.add("hidden"),L.setAttribute("aria-expanded","false")},b=()=>{q.classList.remove("hidden"),L.setAttribute("aria-expanded","true")};L.addEventListener("click",()=>{if(L.getAttribute("aria-expanded")==="true"){d();return}b()}),window.addEventListener("resize",()=>{if(window.innerWidth>=1024){q.classList.remove("hidden"),L.setAttribute("aria-expanded","true");return}d()})}if(w.length>0&&M.length>0){let d=()=>{w.forEach((o)=>{o.setAttribute("aria-expanded","false"),o.closest(".desktop-nav__entry")?.classList.remove("desktop-nav__entry--open")}),M.forEach((o)=>{o.classList.add("hidden")})},b=(o)=>{let s=o.dataset.megaTarget;if(!s)return;d(),o.setAttribute("aria-expanded","true"),o.closest(".desktop-nav__entry")?.classList.add("desktop-nav__entry--open"),document.getElementById(s)?.classList.remove("hidden")};w.forEach((o)=>{o.addEventListener("click",(s)=>{if(window.innerWidth<1024)return;let p=o.getAttribute("aria-expanded")==="true";if(o instanceof HTMLAnchorElement&&p)return;if(s.preventDefault(),p){d();return}b(o)})}),document.addEventListener("click",(o)=>{if(window.innerWidth<1024)return;let s=o.target;if(!(s instanceof Node))return;let p=w.some((a)=>a.contains(s)),f=M.some((a)=>a.contains(s));if(!p&&!f)d()}),document.addEventListener("keydown",(o)=>{if(o.key==="Escape")d()}),window.addEventListener("resize",()=>{if(window.innerWidth<1024)d()})}var N=document.querySelector("button[type='submit']");if(N)N.addEventListener("mouseenter",()=>{_.style.setProperty("--cta-glow","0 0 0 4px rgba(252, 211, 77, 0.12)")}),N.addEventListener("mouseleave",()=>{_.style.setProperty("--cta-glow","0 0 0 0 rgba(0,0,0,0)")});var g=document.querySelector("[data-product-main-image]"),O=g?.dataset.defaultImage||g?.getAttribute("src")||"",E=document.querySelector("[data-product-thumb-carousel]"),D=E?.querySelector("[data-product-thumb-viewport]"),X=E?.querySelector("[data-product-thumb-track]"),H=E?.querySelector("[data-product-thumb-prev]"),R=E?.querySelector("[data-product-thumb-next]"),h=[...document.querySelectorAll("[data-product-thumb-index]")];if(g&&E&&D&&X&&h.length>0){let d=0,b=()=>{h.forEach((a,r)=>{if(r===d){a.classList.remove("border-stone-800"),a.classList.add("border-amber-400/60","ring-1","ring-amber-300/40"),a.setAttribute("aria-current","true");return}a.classList.remove("border-amber-400/60","ring-1","ring-amber-300/40"),a.classList.add("border-stone-800"),a.removeAttribute("aria-current")})},o=(a)=>{h[a]?.scrollIntoView({behavior:"smooth",block:"nearest",inline:"center"})},s=(a)=>{let r=(a+h.length)%h.length,l=h[r],y=l?.dataset.productThumbLarge||l?.dataset.productThumbLargeFallback||"";if(!l||!y)return;if(d=r,g.setAttribute("src",y),l.dataset.productThumbAlt)g.setAttribute("alt",l.dataset.productThumbAlt);b(),o(r)},p=(a)=>{s(d+a)};H?.addEventListener("click",()=>{p(-1)}),R?.addEventListener("click",()=>{p(1)}),h.forEach((a,r)=>{a.addEventListener("click",()=>{s(r)})});let f=h.findIndex((a)=>a.dataset.productThumbLarge===g.getAttribute("src"));s(f>=0?f:0),D.addEventListener("wheel",(a)=>{if(Math.abs(a.deltaY)<=Math.abs(a.deltaX))return;a.preventDefault(),D.scrollBy({left:a.deltaY,behavior:"smooth"})},{passive:!1})}var G=document.querySelector("[data-variant-picker]"),B=document.querySelector("[data-variant-combination]");if(G&&B){let d=[...G.querySelectorAll("[data-variant-group]")],b=document.querySelector("[data-variant-selection-summary]"),o=document.querySelector("[data-product-price-gross]"),s=document.querySelector("[data-product-price-net]"),p=o?.dataset.defaultPriceGross||o?.textContent||"",f=s?.dataset.defaultPriceNet||s?.textContent||"",a=[...document.querySelectorAll("[data-variant-combination-image]")],r=new Map(a.map((e)=>[e.dataset.variantCombinationImage,{imageLarge:e.dataset.imageLarge||"",priceGross:e.dataset.priceGross||"",priceNet:e.dataset.priceNet||""}])),l=(e)=>(e||"").split(",").map((t)=>Number.parseInt(t,10)).filter((t)=>Number.isInteger(t)&&t>0),y=(e,t,n)=>{if(e==="radio"){if(n)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(t)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(e==="select"){if(n)return"w-full rounded-2xl px-4 py-3 text-left text-sm font-medium text-stone-400 opacity-50 transition";if(t)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(n)return"inline-flex min-h-10 min-w-10 items-center justify-center border border-stone-200 p-0.5 opacity-40 transition";if(t)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"},x=(e,t)=>e.filter((n)=>t.includes(n)),I=(e)=>{let t=null;return d.forEach((n)=>{if(n===e)return;let c=n.querySelector("[data-variant-option][data-selected='true']"),i=l(c?.dataset.combinationIds);if(i.length===0)return;if(t===null){t=i;return}t=x(t,i)}),t},j=(e)=>{if(e.dataset.variantSelect!==void 0)return e.querySelector("[data-variant-select-value]")?.textContent?.trim()||"";let t=e.querySelector("[data-variant-option][data-selected='true']");return t?.getAttribute("aria-label")?.trim()||t?.textContent?.trim()||""},z=()=>{let e=[];if(d.forEach((t)=>{let n=t.dataset.variantGroup,c=j(t),i=n?document.querySelector(`[data-variant-current="${n}"]`):null;if(i)i.textContent=c;let S=t.closest(".space-y-3")?.querySelector("p")?.textContent?.trim()||"";if(S&&c)e.push(`${S}: ${c}`)}),b)b.textContent=e.length>0?e.join(" • "):"Choose product options"},T=(e,t)=>{e.querySelectorAll("[data-variant-option]").forEach((n)=>{let c=n===t;n.dataset.selected=c?"true":"false";let i=n.dataset.disabled==="true";n.className=y(n.dataset.variantPresentation,c,i)})},W=(e)=>{if(e.dataset.variantSelect===void 0)return;let t=e.querySelector("[data-variant-select-value]"),n=e.querySelector("[data-variant-option][data-selected='true']");if(t&&n)t.textContent=n.textContent?.trim()||""},F=(e)=>{let t=I(e),n=[...e.querySelectorAll("[data-variant-option]")];n.forEach((u)=>{let S=l(u.dataset.combinationIds),P=t===null||x(S,t).length>0;u.dataset.disabled=P?"false":"true",u.disabled=!P,u.setAttribute("aria-disabled",P?"false":"true")});let c=e.querySelector("[data-variant-option][data-selected='true']");if(c?.dataset.disabled==="true")c.dataset.selected="false";let i=e.querySelector("[data-variant-option][data-selected='true'][data-disabled='false']");if(!i)i=e.querySelector("[data-variant-option][data-disabled='false']");if(i)T(e,i),W(e);else n.forEach((u)=>{u.dataset.selected="false",u.className=y(u.dataset.variantPresentation,!1,!0)})},$=()=>{for(let e=0;e<2;e+=1)d.forEach((t)=>{F(t)})},C=()=>{$();let e=null;if(d.forEach((t)=>{let n=t.querySelector("[data-variant-option][data-selected='true']"),c=l(n?.dataset.combinationIds);if(c.length===0){e=[];return}if(e===null){e=c;return}e=e.filter((i)=>c.includes(i))}),e&&e.length>0){let t=String(e[0]);B.value=t;let n=r.get(t);if(g){let c=n?.imageLarge||O;if(c){g.setAttribute("src",c);let i=h.findIndex((u)=>u.dataset.productThumbLarge===c);if(i>=0)h.forEach((u,S)=>{if(S===i)u.classList.remove("border-stone-800"),u.classList.add("border-amber-400/60","ring-1","ring-amber-300/40"),u.setAttribute("aria-current","true");else u.classList.remove("border-amber-400/60","ring-1","ring-amber-300/40"),u.classList.add("border-stone-800"),u.removeAttribute("aria-current")}),h[i]?.scrollIntoView({behavior:"smooth",block:"nearest",inline:"center"})}}if(o)o.textContent=n?.priceGross||p;if(s)s.textContent=n?.priceNet||f}else{if(B.value="",o)o.textContent=p;if(s)s.textContent=f}z()},A=()=>{d.forEach((e)=>{if(e.dataset.variantSelect===void 0)return;e.querySelector("[data-variant-select-menu]")?.classList.add("hidden"),e.querySelector("[data-variant-select-trigger]")?.setAttribute("aria-expanded","false")})};d.forEach((e)=>{if(e.dataset.variantSelect!==void 0){let t=e.querySelector("[data-variant-select-trigger]"),n=e.querySelector("[data-variant-select-menu]"),c=e.querySelector("[data-variant-select-value]");t?.addEventListener("click",()=>{let i=t.getAttribute("aria-expanded")==="true";if(A(),i)return;n?.classList.remove("hidden"),t.setAttribute("aria-expanded","true")}),e.querySelectorAll("[data-variant-option]").forEach((i)=>{i.addEventListener("click",()=>{if(i.dataset.disabled==="true")return;if(T(e,i),c)c.textContent=i.textContent?.trim()||"";A(),C()})});return}e.querySelectorAll("[data-variant-option]").forEach((t)=>{t.addEventListener("click",()=>{if(t.dataset.disabled==="true")return;T(e,t),C()})})}),C(),document.addEventListener("click",(e)=>{let t=e.target;if(!(t instanceof Node))return;if(!G.contains(t))A()}),document.addEventListener("keydown",(e)=>{if(e.key==="Escape")A()})}var m=document.querySelector("[data-gallery-modal]"),k=m?.querySelector("[data-gallery-main]"),Y=[...document.querySelectorAll("[data-gallery-open]")],J=[...document.querySelectorAll("[data-gallery-close]")],v=[...document.querySelectorAll("[data-gallery-thumb]")],K=m?.querySelector("[data-gallery-prev]"),Q=m?.querySelector("[data-gallery-next]"),V=m?.querySelector("[data-gallery-thumb-viewport]"),U=m?.querySelector("[data-gallery-thumb-prev]"),Z=m?.querySelector("[data-gallery-thumb-next]");if(m&&k&&Y.length>0){let d=0,b=!1,o=()=>{let l=document.querySelector("[data-product-main-image]")?.getAttribute("src")||"";if(l){let y=v.findIndex((x)=>x.dataset.galleryThumb===l);if(y>=0)f(y)}m.classList.remove("hidden"),m.setAttribute("aria-hidden","false"),document.body.style.overflow="hidden"},s=()=>{m.classList.add("hidden"),m.setAttribute("aria-hidden","true"),document.body.style.overflow=""},p=(r)=>{if(v.forEach((l)=>{l.classList.remove("border-amber-400/60"),l.classList.add("border-stone-800")}),r)r.classList.remove("border-stone-800"),r.classList.add("border-amber-400/60"),r.scrollIntoView({block:"nearest",inline:"nearest"})},f=(r)=>{if(v.length===0)return;let l=(r+v.length)%v.length,y=v[l],x=y.dataset.galleryThumb,I=y.dataset.galleryAlt||k.getAttribute("alt")||"";if(!x)return;d=l,k.setAttribute("src",x),k.setAttribute("alt",I),p(y)},a=(r)=>{if(v.length<=1)return;f(d+r)};U?.addEventListener("click",()=>{a(-1)}),Z?.addEventListener("click",()=>{a(1)}),Y.forEach((r)=>{r.addEventListener("click",o)}),J.forEach((r)=>{r.addEventListener("click",s)}),v.forEach((r,l)=>{if(l===0)f(0);r.addEventListener("click",()=>{f(l)})}),K?.addEventListener("click",()=>{a(-1)}),Q?.addEventListener("click",()=>{a(1)}),V?.addEventListener("wheel",(r)=>{if(window.innerWidth>=1024){r.preventDefault(),V.scrollBy({top:r.deltaY,behavior:"smooth"});return}if(Math.abs(r.deltaY)<=Math.abs(r.deltaX))return;r.preventDefault(),V.scrollBy({left:r.deltaY,behavior:"smooth"})},{passive:!1}),m.addEventListener("click",(r)=>{if(r.target===m)s()}),m.addEventListener("wheel",(r)=>{if(m.getAttribute("aria-hidden")!=="false"||v.length<=1)return;if(Math.abs(r.deltaY)<10)return;if(r.preventDefault(),b)return;b=!0,a(r.deltaY>0?1:-1),window.setTimeout(()=>{b=!1},180)},{passive:!1}),document.addEventListener("keydown",(r)=>{if(m.getAttribute("aria-hidden")!=="false")return;if(r.key==="Escape"){s();return}if(r.key==="ArrowLeft"){r.preventDefault(),a(-1);return}if(r.key==="ArrowRight")r.preventDefault(),a(1)})} diff --git a/web/src/app.css b/web/src/app.css index d2ec6ae..61e7080 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -32,7 +32,7 @@ @layer components { .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 { diff --git a/web/src/app.js b/web/src/app.js index f7b1b27..b493e03 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -106,3 +106,559 @@ if (cartButton) { root.style.setProperty("--cta-glow", "0 0 0 0 rgba(0,0,0,0)"); }); } + +const productMainImage = document.querySelector("[data-product-main-image]"); +const defaultProductImage = productMainImage?.dataset.defaultImage || productMainImage?.getAttribute("src") || ""; +const productThumbCarousel = document.querySelector("[data-product-thumb-carousel]"); +const productThumbViewport = productThumbCarousel?.querySelector("[data-product-thumb-viewport]"); +const productThumbTrack = productThumbCarousel?.querySelector("[data-product-thumb-track]"); +const productThumbPrev = productThumbCarousel?.querySelector("[data-product-thumb-prev]"); +const productThumbNext = productThumbCarousel?.querySelector("[data-product-thumb-next]"); +const productThumbs = [...document.querySelectorAll("[data-product-thumb-index]")]; + +if (productMainImage && productThumbCarousel && productThumbViewport && productThumbTrack && productThumbs.length > 0) { + let activeProductThumbIndex = 0; + + const updateProductThumbState = () => { + productThumbs.forEach((thumb, index) => { + if (index === activeProductThumbIndex) { + thumb.classList.remove("border-stone-800"); + thumb.classList.add("border-amber-400/60", "ring-1", "ring-amber-300/40"); + thumb.setAttribute("aria-current", "true"); + return; + } + thumb.classList.remove("border-amber-400/60", "ring-1", "ring-amber-300/40"); + thumb.classList.add("border-stone-800"); + thumb.removeAttribute("aria-current"); + }); + }; + + const scrollProductThumbIntoView = (index) => { + const thumb = productThumbs[index]; + thumb?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + }; + + const showProductThumb = (index) => { + const normalizedIndex = (index + productThumbs.length) % productThumbs.length; + const thumb = productThumbs[normalizedIndex]; + const nextSrc = thumb?.dataset.productThumbLarge || thumb?.dataset.productThumbLargeFallback || ""; + if (!thumb || !nextSrc) { + return; + } + activeProductThumbIndex = normalizedIndex; + productMainImage.setAttribute("src", nextSrc); + if (thumb.dataset.productThumbAlt) { + productMainImage.setAttribute("alt", thumb.dataset.productThumbAlt); + } + updateProductThumbState(); + scrollProductThumbIntoView(normalizedIndex); + }; + + const stepProductThumbs = (direction) => { + showProductThumb(activeProductThumbIndex + direction); + }; + + productThumbPrev?.addEventListener("click", () => { + stepProductThumbs(-1); + }); + + productThumbNext?.addEventListener("click", () => { + stepProductThumbs(1); + }); + + productThumbs.forEach((thumb, index) => { + thumb.addEventListener("click", () => { + showProductThumb(index); + }); + }); + + const initialIndex = productThumbs.findIndex((thumb) => thumb.dataset.productThumbLarge === productMainImage.getAttribute("src")); + showProductThumb(initialIndex >= 0 ? initialIndex : 0); + + productThumbViewport.addEventListener( + "wheel", + (event) => { + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) { + return; + } + event.preventDefault(); + productThumbViewport.scrollBy({ left: event.deltaY, behavior: "smooth" }); + }, + { passive: false }, + ); +} + +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); + if (productMainImage) { + const nextImage = combinationData?.imageLarge || defaultProductImage; + if (nextImage) { + productMainImage.setAttribute("src", nextImage); + const matchingThumbIndex = productThumbs.findIndex((thumb) => thumb.dataset.productThumbLarge === nextImage); + if (matchingThumbIndex >= 0) { + productThumbs.forEach((thumb, index) => { + if (index === matchingThumbIndex) { + thumb.classList.remove("border-stone-800"); + thumb.classList.add("border-amber-400/60", "ring-1", "ring-amber-300/40"); + thumb.setAttribute("aria-current", "true"); + } else { + thumb.classList.remove("border-amber-400/60", "ring-1", "ring-amber-300/40"); + thumb.classList.add("border-stone-800"); + thumb.removeAttribute("aria-current"); + } + }); + productThumbs[matchingThumbIndex]?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + } + } + } + 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 galleryThumbs = [...document.querySelectorAll("[data-gallery-thumb]")]; +const galleryPrev = galleryModal?.querySelector("[data-gallery-prev]"); +const galleryNext = galleryModal?.querySelector("[data-gallery-next]"); +const galleryThumbViewport = galleryModal?.querySelector("[data-gallery-thumb-viewport]"); +const galleryThumbPrev = galleryModal?.querySelector("[data-gallery-thumb-prev]"); +const galleryThumbNext = galleryModal?.querySelector("[data-gallery-thumb-next]"); + +if (galleryModal && galleryMain && galleryOpeners.length > 0) { + let activeIndex = 0; + let wheelLocked = false; + + const openGallery = () => { + const productMainImage = document.querySelector("[data-product-main-image]"); + const currentMainSrc = productMainImage?.getAttribute("src") || ""; + if (currentMainSrc) { + const matchingIndex = galleryThumbs.findIndex((thumb) => thumb.dataset.galleryThumb === currentMainSrc); + if (matchingIndex >= 0) { + showGalleryImage(matchingIndex); + } + } + 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 = ""; + }; + + const setActiveThumb = (activeThumb) => { + galleryThumbs.forEach((thumb) => { + thumb.classList.remove("border-amber-400/60"); + thumb.classList.add("border-stone-800"); + }); + if (activeThumb) { + activeThumb.classList.remove("border-stone-800"); + activeThumb.classList.add("border-amber-400/60"); + activeThumb.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + }; + + const showGalleryImage = (index) => { + if (galleryThumbs.length === 0) return; + const normalizedIndex = (index + galleryThumbs.length) % galleryThumbs.length; + const thumb = galleryThumbs[normalizedIndex]; + const nextSrc = thumb.dataset.galleryThumb; + const nextAlt = thumb.dataset.galleryAlt || galleryMain.getAttribute("alt") || ""; + if (!nextSrc) return; + activeIndex = normalizedIndex; + galleryMain.setAttribute("src", nextSrc); + galleryMain.setAttribute("alt", nextAlt); + setActiveThumb(thumb); + }; + + const stepGallery = (direction) => { + if (galleryThumbs.length <= 1) return; + showGalleryImage(activeIndex + direction); + }; + + galleryThumbPrev?.addEventListener("click", () => { + stepGallery(-1); + }); + + galleryThumbNext?.addEventListener("click", () => { + stepGallery(1); + }); + + galleryOpeners.forEach((trigger) => { + trigger.addEventListener("click", openGallery); + }); + + galleryClosers.forEach((trigger) => { + trigger.addEventListener("click", closeGallery); + }); + + galleryThumbs.forEach((thumb, index) => { + if (index === 0) { + showGalleryImage(0); + } + thumb.addEventListener("click", () => { + showGalleryImage(index); + }); + }); + + galleryPrev?.addEventListener("click", () => { + stepGallery(-1); + }); + + galleryNext?.addEventListener("click", () => { + stepGallery(1); + }); + + galleryThumbViewport?.addEventListener( + "wheel", + (event) => { + if (window.innerWidth >= 1024) { + event.preventDefault(); + galleryThumbViewport.scrollBy({ top: event.deltaY, behavior: "smooth" }); + return; + } + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) { + return; + } + event.preventDefault(); + galleryThumbViewport.scrollBy({ left: event.deltaY, behavior: "smooth" }); + }, + { passive: false }, + ); + + galleryModal.addEventListener("click", (event) => { + if (event.target === galleryModal) { + closeGallery(); + } + }); + + galleryModal.addEventListener( + "wheel", + (event) => { + if (galleryModal.getAttribute("aria-hidden") !== "false" || galleryThumbs.length <= 1) { + 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); + } + }); +}