package site import ( "net/http" "regexp" "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" ) var ( colorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) validProductLayouts = map[string]bool{"grid": true, "list": true, "grid-overlay": true, "grid-minimal": true} validProductColumns = map[int]bool{0: true, 2: true, 3: true, 4: true, 5: true} // validProductScrolls controls whether the storefront product list // scrolls the normal way (down the page) or sideways as a swipeable // row -- independent from ProductLayout (the card style), so any card // style can be browsed either way. validProductScrolls = map[string]bool{"vertical": true, "horizontal": true} // validHeroPages are the storefront pages the hero banner can be // toggled on for -- kept to a fixed, known set (rather than arbitrary // paths) so a stale/invalid value can never sneak into a page that // doesn't know what to do with it. validHeroPages = map[string]bool{"catalog": true, "home": true, "cart": true, "checkout": true, "account": true, "contact": true} ) func heroPagesToString(pages []string) string { return strings.Join(pages, ",") } func heroPagesFromString(s string) []string { if s == "" { return []string{} } return strings.Split(s, ",") } type Handler struct { service *Service } func NewHandler(service *Service) *Handler { return &Handler{service: service} } type settingsResponse struct { Name string `json:"name"` Description string `json:"description"` OrdersEnabled bool `json:"orders_enabled"` HeaderBgColor string `json:"header_bg_color"` HeaderTextColor string `json:"header_text_color"` BodyBgColor string `json:"body_bg_color"` BodyTextColor string `json:"body_text_color"` FooterBgColor string `json:"footer_bg_color"` FooterTextColor string `json:"footer_text_color"` AccentColor string `json:"accent_color"` ProductLayout string `json:"product_layout"` ProductColumns int `json:"product_columns"` ProductScroll string `json:"product_scroll"` ContactCardTransparent bool `json:"contact_card_transparent"` ContactCardBgColor string `json:"contact_card_bg_color"` LogoMediaID *uuid.UUID `json:"logo_media_id"` HeroMediaID *uuid.UUID `json:"hero_media_id"` HeroPages []string `json:"hero_pages"` CustomerLoginEnabled bool `json:"customer_login_enabled"` CustomerRegistrationEnabled bool `json:"customer_registration_enabled"` CustomerVerificationRequired bool `json:"customer_verification_required"` VerificationContactLinkID *uuid.UUID `json:"verification_contact_link_id"` } func toResponse(s *Settings) settingsResponse { return settingsResponse{ Name: s.Name, Description: s.Description, OrdersEnabled: s.OrdersEnabled, HeaderBgColor: s.HeaderBgColor, HeaderTextColor: s.HeaderTextColor, BodyBgColor: s.BodyBgColor, BodyTextColor: s.BodyTextColor, FooterBgColor: s.FooterBgColor, FooterTextColor: s.FooterTextColor, AccentColor: s.AccentColor, ProductLayout: s.ProductLayout, ProductColumns: s.ProductColumns, ProductScroll: s.ProductScroll, ContactCardTransparent: s.ContactCardTransparent, ContactCardBgColor: s.ContactCardBgColor, LogoMediaID: s.LogoMediaID, HeroMediaID: s.HeroMediaID, HeroPages: heroPagesFromString(s.HeroPages), CustomerLoginEnabled: s.CustomerLoginEnabled, CustomerRegistrationEnabled: s.CustomerRegistrationEnabled, CustomerVerificationRequired: s.CustomerVerificationRequired, VerificationContactLinkID: s.VerificationContactLinkID, } } func (h *Handler) Get(c *gin.Context) { settings, err := h.service.Get(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load site settings"}) return } c.JSON(http.StatusOK, toResponse(settings)) } type updateRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` OrdersEnabled bool `json:"orders_enabled"` } func (h *Handler) Update(c *gin.Context) { var req updateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) return } settings, err := h.service.Update(c.Request.Context(), req.Name, req.Description, req.OrdersEnabled) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update site settings"}) return } c.JSON(http.StatusOK, toResponse(settings)) } // updateAppearanceRequest fields are all optional pointers: the admin can // send just the one option they changed (e.g. {"product_layout": "list"}) // and every other appearance setting keeps its current value. type updateAppearanceRequest struct { HeaderBgColor *string `json:"header_bg_color"` HeaderTextColor *string `json:"header_text_color"` BodyBgColor *string `json:"body_bg_color"` BodyTextColor *string `json:"body_text_color"` FooterBgColor *string `json:"footer_bg_color"` FooterTextColor *string `json:"footer_text_color"` AccentColor *string `json:"accent_color"` ProductLayout *string `json:"product_layout"` ProductColumns *int `json:"product_columns"` ProductScroll *string `json:"product_scroll"` ContactCardTransparent *bool `json:"contact_card_transparent"` ContactCardBgColor *string `json:"contact_card_bg_color"` LogoMediaID *uuid.UUID `json:"logo_media_id"` ClearLogo bool `json:"clear_logo"` HeroMediaID *uuid.UUID `json:"hero_media_id"` ClearHero bool `json:"clear_hero"` HeroPages *[]string `json:"hero_pages"` } func (h *Handler) UpdateAppearance(c *gin.Context) { var req updateAppearanceRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) return } for _, color := range []*string{ req.HeaderBgColor, req.HeaderTextColor, req.BodyBgColor, req.BodyTextColor, req.FooterBgColor, req.FooterTextColor, req.AccentColor, req.ContactCardBgColor, } { if color != nil && !colorPattern.MatchString(*color) { c.JSON(http.StatusBadRequest, gin.H{"error": "colors must be hex values like #RRGGBB"}) return } } if req.ProductLayout != nil && !validProductLayouts[*req.ProductLayout] { c.JSON(http.StatusBadRequest, gin.H{"error": "product_layout must be one of: grid, list, grid-overlay, grid-minimal"}) return } if req.ProductColumns != nil && !validProductColumns[*req.ProductColumns] { c.JSON(http.StatusBadRequest, gin.H{"error": "product_columns must be one of: 0 (auto), 2, 3, 4, 5"}) return } if req.ProductScroll != nil && !validProductScrolls[*req.ProductScroll] { c.JSON(http.StatusBadRequest, gin.H{"error": "product_scroll must be one of: vertical, horizontal"}) return } var heroPagesStr *string if req.HeroPages != nil { for _, page := range *req.HeroPages { if !validHeroPages[page] { c.JSON(http.StatusBadRequest, gin.H{"error": "hero_pages must only contain: home, cart, checkout, account, contact"}) return } } joined := heroPagesToString(*req.HeroPages) heroPagesStr = &joined } in := AppearanceInput{ HeaderBgColor: req.HeaderBgColor, HeaderTextColor: req.HeaderTextColor, BodyBgColor: req.BodyBgColor, BodyTextColor: req.BodyTextColor, FooterBgColor: req.FooterBgColor, FooterTextColor: req.FooterTextColor, AccentColor: req.AccentColor, ProductLayout: req.ProductLayout, ProductColumns: req.ProductColumns, ContactCardTransparent: req.ContactCardTransparent, ContactCardBgColor: req.ContactCardBgColor, ProductScroll: req.ProductScroll, HeroPages: heroPagesStr, } if req.ClearLogo { in.LogoMediaIDSet = true in.LogoMediaID = nil } else if req.LogoMediaID != nil { in.LogoMediaIDSet = true in.LogoMediaID = req.LogoMediaID } if req.ClearHero { in.HeroMediaIDSet = true in.HeroMediaID = nil } else if req.HeroMediaID != nil { in.HeroMediaIDSet = true in.HeroMediaID = req.HeroMediaID } settings, err := h.service.UpdateAppearance(c.Request.Context(), in) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update appearance settings"}) return } c.JSON(http.StatusOK, toResponse(settings)) } // updateCustomerAuthRequest fields are all optional: the admin can flip // just one toggle at a time. ClearVerificationContactLink is a separate // explicit flag (rather than overloading a nil ID) so "field omitted" and // "admin picked no link" are unambiguous. type updateCustomerAuthRequest struct { LoginEnabled *bool `json:"login_enabled"` RegistrationEnabled *bool `json:"registration_enabled"` VerificationRequired *bool `json:"verification_required"` VerificationContactLinkID *uuid.UUID `json:"verification_contact_link_id"` ClearVerificationContactLink bool `json:"clear_verification_contact_link"` } func (h *Handler) UpdateCustomerAuth(c *gin.Context) { var req updateCustomerAuthRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) return } in := CustomerAuthInput{ LoginEnabled: req.LoginEnabled, RegistrationEnabled: req.RegistrationEnabled, VerificationRequired: req.VerificationRequired, } if req.ClearVerificationContactLink { in.VerificationContactLinkSet = true in.VerificationContactLinkID = nil } else if req.VerificationContactLinkID != nil { in.VerificationContactLinkSet = true in.VerificationContactLinkID = req.VerificationContactLinkID } settings, err := h.service.UpdateCustomerAuth(c.Request.Context(), in) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update customer auth settings"}) return } c.JSON(http.StatusOK, toResponse(settings)) }