Files
template-vitrine/backend/internal/modules/site/handler.go
T
Xor290 919c807004
ci-api / test (push) Failing after 8m7s
ci-web / test (push) Failing after 5m5s
chore: build
2026-09-20 12:18:33 +02:00

278 lines
10 KiB
Go

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))
}