chore: build
This commit is contained in:
@@ -3,11 +3,40 @@ package site
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
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
|
||||
@@ -18,13 +47,59 @@ func NewHandler(service *Service) *Handler {
|
||||
}
|
||||
|
||||
type settingsResponse struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Slug string `json:"slug"`
|
||||
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, Slug: s.Slug}
|
||||
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) {
|
||||
@@ -37,9 +112,9 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
OrdersEnabled bool `json:"orders_enabled"`
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
@@ -48,15 +123,155 @@ func (h *Handler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
if !slugPattern.MatchString(req.Slug) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "slug must be lowercase letters, digits and hyphens only"})
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.service.Update(c.Request.Context(), req.Name, req.Description, req.Slug)
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -1,19 +1,80 @@
|
||||
// Package site owns the minimal, config-driven site identity (name,
|
||||
// description, slug). It is the proof of the "code defines capabilities,
|
||||
// admin defines content" pattern: future modules (appearance, menu, pages,
|
||||
// ...) will follow this same shape (single-row or keyed settings table +
|
||||
// description). It is the proof of the "code defines capabilities, admin
|
||||
// defines content" pattern: future modules (appearance, menu, pages, ...)
|
||||
// will follow this same shape (single-row or keyed settings table +
|
||||
// admin-only read/write endpoints).
|
||||
package site
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
ID int16 `gorm:"primaryKey"`
|
||||
Name string
|
||||
Description string
|
||||
Slug string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
// OrdersEnabled is the "vitrine vs boutique" site-mode switch (spec
|
||||
// section 6-8): off hides the cart/checkout entirely on the storefront
|
||||
// (showcase-only, prices shown as indicative), on exposes the full
|
||||
// cart/order flow (still subject to CustomerLoginEnabled below). On by
|
||||
// default so a fresh deployment behaves like a shop out of the box.
|
||||
OrdersEnabled bool
|
||||
|
||||
// Appearance: lets the admin restyle the storefront (header/body/footer
|
||||
// colors, accent color, product listing layout) without touching code.
|
||||
HeaderBgColor string
|
||||
HeaderTextColor string
|
||||
BodyBgColor string
|
||||
BodyTextColor string
|
||||
FooterBgColor string
|
||||
FooterTextColor string
|
||||
AccentColor string
|
||||
ProductLayout string
|
||||
// ProductColumns pins the storefront product grid to a fixed number of
|
||||
// columns per row (2-5). 0 means "auto" -- the grid falls back to
|
||||
// filling as many columns as fit the viewport (see .product-grid in
|
||||
// index.css).
|
||||
ProductColumns int
|
||||
// ProductScroll is "vertical" (products flow down the page, the normal
|
||||
// grid/list layouts) or "horizontal" (the product list becomes a
|
||||
// swipeable sideways-scrolling row) -- independent from ProductLayout,
|
||||
// so any card style can be browsed either way.
|
||||
ProductScroll string
|
||||
|
||||
// Contact link cards (storefront Contact page): transparent by default
|
||||
// so a hero image or the body background shows through the cards
|
||||
// instead of a solid block; ContactCardBgColor is only used when the
|
||||
// admin turns transparency off in favor of a solid color.
|
||||
ContactCardTransparent bool
|
||||
ContactCardBgColor string
|
||||
|
||||
// LogoMediaID replaces the text site name in the storefront header when
|
||||
// set. HeroMediaID is a full-width banner image shown above the normal
|
||||
// page content, only on the pages listed in HeroPages (comma-separated
|
||||
// page keys, e.g. "home,contact" -- see handler.go's validHeroPages).
|
||||
LogoMediaID *uuid.UUID `gorm:"type:uuid"`
|
||||
HeroMediaID *uuid.UUID `gorm:"type:uuid"`
|
||||
HeroPages string
|
||||
|
||||
// Customer accounts: the login page and the registration page are two
|
||||
// independent switches the admin can flip separately (e.g. login-only
|
||||
// for an invite-only shop where accounts are created from the admin
|
||||
// panel, or registration-only during a pre-launch signup window).
|
||||
// CustomerLoginEnabled is also what ordering checks (see
|
||||
// orders.RequireAccountsEnabled): off by default, meaning nobody --
|
||||
// guest or not -- can check out until the admin turns it on, optionally
|
||||
// alongside an admin-approved identity verification too (e.g. for
|
||||
// age/ID-restricted goods).
|
||||
CustomerLoginEnabled bool
|
||||
CustomerRegistrationEnabled bool
|
||||
CustomerVerificationRequired bool
|
||||
VerificationContactLinkID *uuid.UUID `gorm:"type:uuid"`
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Settings) TableName() string { return "site_settings" }
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
type Repository interface {
|
||||
Get(ctx context.Context) (*Settings, error)
|
||||
Update(ctx context.Context, s *Settings) error
|
||||
UpdateAppearance(ctx context.Context, s *Settings) error
|
||||
UpdateCustomerAuth(ctx context.Context, s *Settings) error
|
||||
}
|
||||
|
||||
type gormRepository struct {
|
||||
@@ -30,8 +32,39 @@ func (r *gormRepository) Get(ctx context.Context) (*Settings, error) {
|
||||
func (r *gormRepository) Update(ctx context.Context, s *Settings) error {
|
||||
s.ID = 1
|
||||
return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(map[string]any{
|
||||
"name": s.Name,
|
||||
"description": s.Description,
|
||||
"slug": s.Slug,
|
||||
"name": s.Name,
|
||||
"description": s.Description,
|
||||
"orders_enabled": s.OrdersEnabled,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *gormRepository) UpdateAppearance(ctx context.Context, s *Settings) error {
|
||||
s.ID = 1
|
||||
return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(map[string]any{
|
||||
"header_bg_color": s.HeaderBgColor,
|
||||
"header_text_color": s.HeaderTextColor,
|
||||
"body_bg_color": s.BodyBgColor,
|
||||
"body_text_color": s.BodyTextColor,
|
||||
"footer_bg_color": s.FooterBgColor,
|
||||
"footer_text_color": s.FooterTextColor,
|
||||
"accent_color": s.AccentColor,
|
||||
"product_layout": s.ProductLayout,
|
||||
"product_columns": s.ProductColumns,
|
||||
"product_scroll": s.ProductScroll,
|
||||
"contact_card_transparent": s.ContactCardTransparent,
|
||||
"contact_card_bg_color": s.ContactCardBgColor,
|
||||
"logo_media_id": s.LogoMediaID,
|
||||
"hero_media_id": s.HeroMediaID,
|
||||
"hero_pages": s.HeroPages,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *gormRepository) UpdateCustomerAuth(ctx context.Context, s *Settings) error {
|
||||
s.ID = 1
|
||||
return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(map[string]any{
|
||||
"customer_login_enabled": s.CustomerLoginEnabled,
|
||||
"customer_registration_enabled": s.CustomerRegistrationEnabled,
|
||||
"customer_verification_required": s.CustomerVerificationRequired,
|
||||
"verification_contact_link_id": s.VerificationContactLinkID,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -6,4 +6,12 @@ func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFun
|
||||
group := rg.Group("/admin/site-settings")
|
||||
group.GET("", h.Get)
|
||||
group.PUT("", requireAdmin, h.Update)
|
||||
group.PUT("/appearance", requireAdmin, h.UpdateAppearance)
|
||||
group.PUT("/customer-auth", requireAdmin, h.UpdateCustomerAuth)
|
||||
}
|
||||
|
||||
// RegisterPublicRoutes exposes the site identity (name, description)
|
||||
// under a plain, non-admin-prefixed path for the storefront to read.
|
||||
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) {
|
||||
rg.GET("/site-settings", h.Get)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package site
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
@@ -14,10 +18,190 @@ func (s *Service) Get(ctx context.Context) (*Settings, error) {
|
||||
return s.repo.Get(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, name, description, slug string) (*Settings, error) {
|
||||
settings := &Settings{Name: name, Description: description, Slug: slug}
|
||||
func (s *Service) Update(ctx context.Context, name, description string, ordersEnabled bool) (*Settings, error) {
|
||||
settings := &Settings{Name: name, Description: description, OrdersEnabled: ordersEnabled}
|
||||
if err := s.repo.Update(ctx, settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.Get(ctx)
|
||||
}
|
||||
|
||||
// AppearanceInput is updated independently from site identity (name/slug):
|
||||
// the "Appearance" admin page only ever touches these fields, so it must
|
||||
// not be blocked by identity validation (e.g. an unset slug) or clobber
|
||||
// name/description/slug on save. Each field is a pointer so the admin can
|
||||
// change a single option (e.g. just the product layout) without resending
|
||||
// every color -- nil fields keep their current, already-saved value.
|
||||
type AppearanceInput struct {
|
||||
HeaderBgColor *string
|
||||
HeaderTextColor *string
|
||||
BodyBgColor *string
|
||||
BodyTextColor *string
|
||||
FooterBgColor *string
|
||||
FooterTextColor *string
|
||||
AccentColor *string
|
||||
ProductLayout *string
|
||||
ProductColumns *int
|
||||
ProductScroll *string
|
||||
|
||||
ContactCardTransparent *bool
|
||||
ContactCardBgColor *string
|
||||
|
||||
// LogoMediaIDSet/HeroMediaIDSet distinguish "the admin didn't touch
|
||||
// this field" (false: keep the current value) from "the admin
|
||||
// explicitly chose an image, possibly clearing it to nil" (true: use
|
||||
// the ID as-is, nil included) -- same reasoning as
|
||||
// CustomerAuthInput.VerificationContactLinkSet.
|
||||
LogoMediaIDSet bool
|
||||
LogoMediaID *uuid.UUID
|
||||
HeroMediaIDSet bool
|
||||
HeroMediaID *uuid.UUID
|
||||
HeroPages *string
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAppearance(ctx context.Context, in AppearanceInput) (*Settings, error) {
|
||||
current, err := s.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings := &Settings{
|
||||
HeaderBgColor: applyIfSet(in.HeaderBgColor, current.HeaderBgColor),
|
||||
HeaderTextColor: applyIfSet(in.HeaderTextColor, current.HeaderTextColor),
|
||||
BodyBgColor: applyIfSet(in.BodyBgColor, current.BodyBgColor),
|
||||
BodyTextColor: applyIfSet(in.BodyTextColor, current.BodyTextColor),
|
||||
FooterBgColor: applyIfSet(in.FooterBgColor, current.FooterBgColor),
|
||||
FooterTextColor: applyIfSet(in.FooterTextColor, current.FooterTextColor),
|
||||
AccentColor: applyIfSet(in.AccentColor, current.AccentColor),
|
||||
ProductLayout: applyIfSet(in.ProductLayout, current.ProductLayout),
|
||||
ProductColumns: applyIntIfSet(in.ProductColumns, current.ProductColumns),
|
||||
ProductScroll: applyIfSet(in.ProductScroll, current.ProductScroll),
|
||||
ContactCardTransparent: applyBoolIfSet(in.ContactCardTransparent, current.ContactCardTransparent),
|
||||
ContactCardBgColor: applyIfSet(in.ContactCardBgColor, current.ContactCardBgColor),
|
||||
LogoMediaID: current.LogoMediaID,
|
||||
HeroMediaID: current.HeroMediaID,
|
||||
HeroPages: applyIfSet(in.HeroPages, current.HeroPages),
|
||||
}
|
||||
if in.LogoMediaIDSet {
|
||||
settings.LogoMediaID = in.LogoMediaID
|
||||
}
|
||||
if in.HeroMediaIDSet {
|
||||
settings.HeroMediaID = in.HeroMediaID
|
||||
}
|
||||
if err := s.repo.UpdateAppearance(ctx, settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.Get(ctx)
|
||||
}
|
||||
|
||||
func applyIfSet(value *string, fallback string) string {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func applyIntIfSet(value *int, fallback int) int {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
// CustomerAuthInput is updated independently from identity/appearance, same
|
||||
// reasoning as AppearanceInput: the "Customer accounts" admin page only
|
||||
// ever touches these fields. LoginEnabled and RegistrationEnabled are two
|
||||
// independent switches (e.g. an invite-only shop can enable login while
|
||||
// keeping registration off), so each is its own optional pointer.
|
||||
type CustomerAuthInput struct {
|
||||
LoginEnabled *bool
|
||||
RegistrationEnabled *bool
|
||||
VerificationRequired *bool
|
||||
// VerificationContactLinkSet distinguishes "the admin didn't touch this
|
||||
// field" (false: keep the current value) from "the admin explicitly
|
||||
// chose a link, possibly clearing it to nil" (true: use
|
||||
// VerificationContactLinkID as-is, nil included).
|
||||
VerificationContactLinkSet bool
|
||||
VerificationContactLinkID *uuid.UUID
|
||||
}
|
||||
|
||||
func (s *Service) UpdateCustomerAuth(ctx context.Context, in CustomerAuthInput) (*Settings, error) {
|
||||
current, err := s.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings := &Settings{
|
||||
CustomerLoginEnabled: applyBoolIfSet(in.LoginEnabled, current.CustomerLoginEnabled),
|
||||
CustomerRegistrationEnabled: applyBoolIfSet(in.RegistrationEnabled, current.CustomerRegistrationEnabled),
|
||||
CustomerVerificationRequired: applyBoolIfSet(in.VerificationRequired, current.CustomerVerificationRequired),
|
||||
VerificationContactLinkID: current.VerificationContactLinkID,
|
||||
}
|
||||
if in.VerificationContactLinkSet {
|
||||
settings.VerificationContactLinkID = in.VerificationContactLinkID
|
||||
}
|
||||
if err := s.repo.UpdateCustomerAuth(ctx, settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.Get(ctx)
|
||||
}
|
||||
|
||||
func applyBoolIfSet(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
// CustomerLoginEnabled is consumed by auth.CustomerHandler to gate the
|
||||
// customer login page/endpoint behind the admin's toggle.
|
||||
func (s *Service) CustomerLoginEnabled(ctx context.Context) (bool, error) {
|
||||
settings, err := s.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return settings.CustomerLoginEnabled, nil
|
||||
}
|
||||
|
||||
// CustomerRegistrationEnabled is consumed by auth.CustomerHandler to gate
|
||||
// the customer registration page/endpoint behind the admin's toggle,
|
||||
// independently of CustomerLoginEnabled.
|
||||
func (s *Service) CustomerRegistrationEnabled(ctx context.Context) (bool, error) {
|
||||
settings, err := s.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return settings.CustomerRegistrationEnabled, nil
|
||||
}
|
||||
|
||||
// CustomerAccountsAvailable is consumed by users.Service.Create to prevent
|
||||
// creating customer-role users when neither login nor registration is
|
||||
// enabled -- such an account could never be used to sign in.
|
||||
func (s *Service) CustomerAccountsAvailable(ctx context.Context) (bool, error) {
|
||||
settings, err := s.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return settings.CustomerLoginEnabled || settings.CustomerRegistrationEnabled, nil
|
||||
}
|
||||
|
||||
// CustomerCheckoutSettings is consumed by orders.RequireAccountsEnabled /
|
||||
// RequireApprovedVerificationIfNeeded to gate order creation.
|
||||
func (s *Service) CustomerCheckoutSettings(ctx context.Context) (accountsEnabled, verificationRequired bool, err error) {
|
||||
settings, err := s.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return settings.CustomerLoginEnabled, settings.CustomerVerificationRequired, nil
|
||||
}
|
||||
|
||||
// OrdersEnabled is consumed by orders.RequireOrdersEnabled: the site-wide
|
||||
// "vitrine vs boutique" switch, checked before any of the customer-account
|
||||
// gates so a pure showcase site never has to think about accounts at all.
|
||||
func (s *Service) OrdersEnabled(ctx context.Context) (bool, error) {
|
||||
settings, err := s.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return settings.OrdersEnabled, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user