chore: build

This commit is contained in:
Xor290
2026-09-08 17:33:32 +02:00
parent 42ed11bc22
commit 624df79974
14 changed files with 1066 additions and 60 deletions
+12 -3
View File
@@ -130,11 +130,14 @@ func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) { func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
var basket models.Panier var basket models.Panier
err := d.GDB.Transaction(func(tx *gorm.DB) error { err := d.GDB.Transaction(func(tx *gorm.DB) error {
var currentStock float64 var productInfo struct {
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&currentStock).Error; err != nil { Stock float64 `gorm:"column:stock"`
Category string `gorm:"column:category"`
}
if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil {
return fmt.Errorf("erreur lecture stock: %w", err) return fmt.Errorf("erreur lecture stock: %w", err)
} }
if currentStock < quantity { if productInfo.Stock < quantity {
return fmt.Errorf("stock insuffisant") return fmt.Errorf("stock insuffisant")
} }
@@ -148,6 +151,12 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 { productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 {
return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity) return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
} }
// Une promotion active pour ce produit/quantité/catégorie s'applique
// automatiquement au prix facturé — indépendamment des points de
// fidélité (contrairement aux récompenses par palier).
if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok {
priceResult.Price = discounted
}
var existing struct { var existing struct {
ID int `gorm:"column:id"` ID int `gorm:"column:id"`
+48
View File
@@ -0,0 +1,48 @@
package db
import (
"gestion/models"
"math"
)
// ResolvePromotionDiscount retourne le pourcentage de réduction actif pour un
// produit, sa catégorie catalogue et une quantité donnés, si une promotion
// configurée dans les settings couvre exactement ce couple (produit,
// quantité) — contrairement aux récompenses, aucun seuil de points n'entre
// en jeu : la promotion s'applique à toute commande de cette quantité.
func ResolvePromotionDiscount(settings *models.AppSettings, productID int, category string, quantity float64) (float64, bool) {
if settings == nil || !settings.PromotionsEnabled {
return 0, false
}
for _, promo := range settings.Promotions {
if promo.Category != category || promo.DiscountPercent <= 0 {
continue
}
if promo.AllProducts {
if promo.Quantity == quantity {
return promo.DiscountPercent, true
}
continue
}
for _, pq := range promo.Products {
if pq.ProductID == productID && pq.Quantity == quantity {
return promo.DiscountPercent, true
}
}
}
return 0, false
}
// ApplyPromotionToPrice applique la réduction (si une promotion couvre ce
// produit/quantité/catégorie) au prix catalogue donné, arrondi au centime.
func (d *Database) ApplyPromotionToPrice(productID int, category string, quantity, price float64) (float64, bool) {
settings, err := d.GetSettings()
if err != nil {
return price, false
}
discount, ok := ResolvePromotionDiscount(&settings, productID, category, quantity)
if !ok {
return price, false
}
return math.Round(price*(1-discount/100)*100) / 100, true
}
+22
View File
@@ -142,6 +142,13 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
settings.PointsReward = &reward settings.PointsReward = &reward
} }
} }
case "promotions_enabled":
settings.PromotionsEnabled = row.Value == "true"
case "promotions":
var promotions []models.CategoryPromotionConfig
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
settings.Promotions = promotions
}
case "referral_enabled": case "referral_enabled":
settings.ReferralEnabled = row.Value == "true" settings.ReferralEnabled = row.Value == "true"
case "referral_amount": case "referral_amount":
@@ -263,6 +270,19 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
return fmt.Errorf("erreur sérialisation points_reward: %w", err) return fmt.Errorf("erreur sérialisation points_reward: %w", err)
} }
if s.Promotions == nil {
s.Promotions = []models.CategoryPromotionConfig{}
}
for i := range s.Promotions {
if s.Promotions[i].Products == nil {
s.Promotions[i].Products = []models.PromotionProductQuantity{}
}
}
promotionsJSON, err := json.Marshal(s.Promotions)
if err != nil {
return fmt.Errorf("erreur sérialisation promotions: %w", err)
}
if s.NowPaymentsCurrencies == nil { if s.NowPaymentsCurrencies == nil {
s.NowPaymentsCurrencies = []string{} s.NowPaymentsCurrencies = []string{}
} }
@@ -302,6 +322,8 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
{"points_enabled", boolStr(s.PointsEnabled)}, {"points_enabled", boolStr(s.PointsEnabled)},
{"points_pools", string(poolsJSON)}, {"points_pools", string(poolsJSON)},
{"points_reward", string(rewardJSON)}, {"points_reward", string(rewardJSON)},
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
{"promotions", string(promotionsJSON)},
{"referral_enabled", boolStr(s.ReferralEnabled)}, {"referral_enabled", boolStr(s.ReferralEnabled)},
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)}, {"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)}, {"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
+33
View File
@@ -8,6 +8,7 @@ import (
"gestion/utils" "gestion/utils"
"io" "io"
"log" "log"
"math"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"strconv" "strconv"
@@ -412,6 +413,7 @@ func GetAllProducts(c *gin.Context) {
if role != "admin" && role != "cabine" { if role != "admin" && role != "cabine" {
products = filterActivePrices(products) products = filterActivePrices(products)
} }
products = applyPromotions(products, database)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": products, "data": products,
@@ -450,6 +452,7 @@ func GetProductsByCategory(c *gin.Context) {
if roleCtx != "admin" && roleCtx != "cabine" { if roleCtx != "admin" && roleCtx != "cabine" {
products = filterActivePrices(products) products = filterActivePrices(products)
} }
products = applyPromotions(products, database)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": products, "data": products,
@@ -482,6 +485,7 @@ func GetProductByID(c *gin.Context) {
if role != "admin" && role != "cabine" { if role != "admin" && role != "cabine" {
filterActivepricesSingle(&product) filterActivepricesSingle(&product)
} }
applyPromotionsSingle(&product, database)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
@@ -1002,3 +1006,32 @@ func filterActivepricesSingle(product *models.Product) {
} }
product.Prices = activePrices product.Prices = activePrices
} }
// applyPromotions annote chaque palier de prix éligible avec le prix promo
// (PromoPrice/PromoPercent) si une promotion couvre ce produit/quantité —
// affichage seulement, le prix catalogue (Price) n'est jamais modifié ici ;
// le prix réellement facturé est recalculé indépendamment dans AddToBasket.
func applyPromotions(products []models.Product, database *db.Database) []models.Product {
settings, err := database.GetSettings()
if err != nil || !settings.PromotionsEnabled {
return products
}
for i := range products {
for j := range products[i].Prices {
pr := &products[i].Prices[j]
discount, ok := db.ResolvePromotionDiscount(&settings, products[i].ID, products[i].Category, pr.Quantity)
if !ok {
continue
}
promoPrice := math.Round(pr.Price*(1-discount/100)*100) / 100
pr.PromoPrice = &promoPrice
pr.PromoPercent = discount
}
}
return products
}
func applyPromotionsSingle(product *models.Product, database *db.Database) {
products := applyPromotions([]models.Product{*product}, database)
*product = products[0]
}
+7
View File
@@ -32,6 +32,13 @@ type ProductPrice struct {
// TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et // TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et
// seulement source du bug. // seulement source du bug.
ActivePrice bool `json:"active_price" gorm:"column:active_price"` ActivePrice bool `json:"active_price" gorm:"column:active_price"`
// Champs transitoires (non persistés, gorm:"-") : annotés à la volée sur
// les endpoints de lecture client si une promotion s'applique à ce palier
// précis (voir handlers.applyPromotions) — permet d'afficher le prix
// barré + le prix promo sans toucher au prix catalogue réel.
PromoPrice *float64 `json:"promo_price,omitempty" gorm:"-"`
PromoPercent float64 `json:"promo_percent,omitempty" gorm:"-"`
} }
func (ProductPrice) TableName() string { return "product_prices" } func (ProductPrice) TableName() string { return "product_prices" }
+51 -22
View File
@@ -59,6 +59,33 @@ type PointsReward struct {
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type + quantité par catégorie CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type + quantité par catégorie
} }
// PromotionProductQuantity associe un produit à sa propre quantité en promo,
// pour le cas où une catégorie n'est pas configurée en "tous les produits" —
// même logique que RewardProductQuantity mais pour les promotions.
type PromotionProductQuantity struct {
ProductID int `json:"product_id"`
Quantity float64 `json:"quantity"`
}
// CategoryPromotionConfig définit une promotion (réduction en %) appliquée
// automatiquement au prix catalogue d'un produit pour une quantité donnée —
// contrairement à RewardCategoryConfig, ça ne dépend d'aucun seuil de points :
// le prix réduit s'applique à tout client qui commande ce produit à cette
// quantité, affiché directement sur le produit. La quantité correspond au
// palier de prix catalogue existant (voir GetActiveProductPrice), pas une
// valeur libre.
//
// Si AllProducts = true, Quantity s'applique uniformément à tous les produits
// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans
// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré).
type CategoryPromotionConfig struct {
Category string `json:"category"` // nom de la catégorie
DiscountPercent float64 `json:"discount_percent"` // pourcentage de réduction libre (ex: 10, 20, 33.5)
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true
Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
}
// DaySchedule représente les horaires de livraison pour un jour de la semaine // DaySchedule représente les horaires de livraison pour un jour de la semaine
type DaySchedule struct { type DaySchedule struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -106,28 +133,30 @@ type DeliveryModeConfig struct {
// AppSettings contient les paramètres globaux de l'application // AppSettings contient les paramètres globaux de l'application
type AppSettings struct { type AppSettings struct {
PenaltiesEnabled bool `json:"penalties_enabled"` PenaltiesEnabled bool `json:"penalties_enabled"`
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable) PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces) ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"]) NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @) PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
// Palette de couleurs — espace admin // Palette de couleurs — espace admin
AdminColorPrimary string `json:"admin_color_primary"` AdminColorPrimary string `json:"admin_color_primary"`
AdminColorSecondary string `json:"admin_color_secondary"` AdminColorSecondary string `json:"admin_color_secondary"`
+271
View File
@@ -0,0 +1,271 @@
package tests
import (
"encoding/json"
"gestion/db"
"gestion/handlers"
"gestion/models"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
)
// configurePromotionSettings applique les settings donnés (avec Promotions)
// via testDB.UpdateSettings, comme le ferait l'admin — testDB.UpdateSettings
// normalise déjà les slices nil, donc ce helper reste minimal.
func configurePromotionSettings(t *testing.T, settings models.AppSettings) {
t.Helper()
if err := testDB.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
}
// ── Persistance des settings (save→reload) ──────────────────────────────────
func TestUpdateSettings_PromotionsRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{
Category: "test",
DiscountPercent: 15.5,
AllProducts: false,
Products: []models.PromotionProductQuantity{
{ProductID: 111, Quantity: 2},
{ProductID: 222, Quantity: 1},
},
},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if !loaded.PromotionsEnabled {
t.Fatal("promotions_enabled devrait être true après reload")
}
if len(loaded.Promotions) != 1 {
t.Fatalf("promotions: got=%d want=1: %+v", len(loaded.Promotions), loaded.Promotions)
}
promo := loaded.Promotions[0]
if promo.Category != "test" || promo.DiscountPercent != 15.5 {
t.Errorf("promo mal persistée: got=%+v", promo)
}
if len(promo.Products) != 2 || promo.Products[0].ProductID != 111 || promo.Products[0].Quantity != 2 {
t.Errorf("products mal persistés: got=%+v", promo.Products)
}
// Désactivation : doit persister à false, pas de résurrection (même
// classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil).
s.PromotionsEnabled = false
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings (désactivation): %v", err)
}
loaded, err = testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings (désactivation): %v", err)
}
if loaded.PromotionsEnabled {
t.Error("promotions_enabled devrait rester false après désactivation")
}
}
// ── Résolution de la réduction (logique pure) ───────────────────────────────
func TestResolvePromotionDiscount_MatchesAllProductsAtConfiguredQuantity(t *testing.T) {
settings := &models.AppSettings{
PromotionsEnabled: true,
Promotions: []models.CategoryPromotionConfig{
{Category: "fleurs", DiscountPercent: 20, AllProducts: true, Quantity: 5},
},
}
discount, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 5)
if !ok || discount != 20 {
t.Errorf("got discount=%.2f ok=%v want=20/true", discount, ok)
}
// Mauvaise quantité : pas de promo.
if _, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 3); ok {
t.Error("ne devrait pas matcher une quantité différente de celle configurée")
}
// Mauvaise catégorie : pas de promo.
if _, ok := db.ResolvePromotionDiscount(settings, 42, "autre", 5); ok {
t.Error("ne devrait pas matcher une catégorie différente")
}
}
func TestResolvePromotionDiscount_DisabledReturnsNoDiscount(t *testing.T) {
settings := &models.AppSettings{
PromotionsEnabled: false,
Promotions: []models.CategoryPromotionConfig{
{Category: "fleurs", DiscountPercent: 20, AllProducts: true, Quantity: 5},
},
}
if _, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 5); ok {
t.Error("aucune promo ne doit s'appliquer si promotions_enabled = false")
}
}
func TestResolvePromotionDiscount_PerProductSelection(t *testing.T) {
settings := &models.AppSettings{
PromotionsEnabled: true,
Promotions: []models.CategoryPromotionConfig{
{
Category: "fleurs",
AllProducts: false,
Products: []models.PromotionProductQuantity{
{ProductID: 1, Quantity: 2},
},
DiscountPercent: 10,
},
},
}
if discount, ok := db.ResolvePromotionDiscount(settings, 1, "fleurs", 2); !ok || discount != 10 {
t.Errorf("produit sélectionné à la bonne quantité: got discount=%.2f ok=%v", discount, ok)
}
if _, ok := db.ResolvePromotionDiscount(settings, 1, "fleurs", 3); ok {
t.Error("mauvaise quantité pour ce produit : ne doit pas matcher")
}
if _, ok := db.ResolvePromotionDiscount(settings, 2, "fleurs", 2); ok {
t.Error("produit non sélectionné : ne doit pas matcher")
}
}
// ── AddToBasket applique réellement la réduction au prix facturé ───────────
func TestAddToBasket_AppliesPromotionDiscount(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_basket_applies")
productID := newTestProduct(t, "PromoBasketApplies", 10)
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", DiscountPercent: 20, AllProducts: true, Quantity: 1},
}
configurePromotionSettings(t, s)
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Price != 8.0 {
t.Errorf("prix attendu = 10€ - 20%% = 8.00€: got=%.2f", basket.Price)
}
}
func TestAddToBasket_NoDiscountWhenPromotionsDisabled(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_basket_disabled")
productID := newTestProduct(t, "PromoBasketDisabled", 10)
s := db.DefaultSettings()
s.PromotionsEnabled = false
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", DiscountPercent: 20, AllProducts: true, Quantity: 1},
}
configurePromotionSettings(t, s)
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Price != 10.0 {
t.Errorf("promotions désactivées: le prix catalogue plein doit s'appliquer: got=%.2f want=10.00", basket.Price)
}
}
func TestAddToBasket_NoDiscountForDifferentProductSelection(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_basket_other_product")
promotedID := newTestProduct(t, "PromoBasketOtherPromoted", 10)
otherID := newTestProduct(t, "PromoBasketOtherPlain", 10)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{
Category: "test",
AllProducts: false,
Products: []models.PromotionProductQuantity{{ProductID: promotedID, Quantity: 1}},
DiscountPercent: 50,
},
}
configurePromotionSettings(t, s)
promotedBasket, err := testDB.AddToBasket(username, promotedID, 1)
if err != nil {
t.Fatalf("AddToBasket (promu): %v", err)
}
if promotedBasket.Price != 5.0 {
t.Errorf("produit promu: prix attendu = 10€ - 50%% = 5.00€: got=%.2f", promotedBasket.Price)
}
otherBasket, err := testDB.AddToBasket(username, otherID, 1)
if err != nil {
t.Fatalf("AddToBasket (autre): %v", err)
}
if otherBasket.Price != 10.0 {
t.Errorf("produit non sélectionné dans la promo: prix plein attendu=10.00: got=%.2f", otherBasket.Price)
}
}
// ── Affichage catalogue : le prix promo est annoté sur le palier concerné ──
func TestGetProductByID_AnnotatesPromoPriceOnMatchingTier(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
productID := newTestProduct(t, "PromoDisplayAnnotated", 10)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", DiscountPercent: 25, AllProducts: true, Quantity: 1},
}
configurePromotionSettings(t, s)
idStr := strconv.Itoa(productID)
req := httptest.NewRequest(http.MethodGet, "/api/v1/products/"+idStr, nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Params = gin.Params{{Key: "id", Value: idStr}}
c.Set("role", "client")
handlers.GetProductByID(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Data models.Product `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
if len(resp.Data.Prices) != 1 {
t.Fatalf("attendu 1 palier de prix: got=%+v", resp.Data.Prices)
}
tier := resp.Data.Prices[0]
if tier.PromoPrice == nil {
t.Fatal("PromoPrice devrait être renseigné pour ce palier couvert par la promo")
}
if *tier.PromoPrice != 7.5 {
t.Errorf("promo_price attendu = 10€ - 25%% = 7.50€: got=%.2f", *tier.PromoPrice)
}
if tier.PromoPercent != 25 {
t.Errorf("promo_percent attendu=25: got=%.2f", tier.PromoPercent)
}
}
@@ -1,9 +1,16 @@
package tests package tests
import ( import (
"bytes"
"encoding/json"
"gestion/db" "gestion/db"
"gestion/handlers"
"gestion/models" "gestion/models"
"net/http"
"net/http/httptest"
"testing" "testing"
"github.com/gin-gonic/gin"
) )
// resetSettingsAfterTest restaure les settings par défaut à la fin du test — // resetSettingsAfterTest restaure les settings par défaut à la fin du test —
@@ -304,3 +311,80 @@ func TestUpdateSettings_ColorAndGradientFieldsRoundTrip(t *testing.T) {
t.Errorf("client_title_gradient: got from=%q to=%q", loaded.ClientTitleGradientFrom, loaded.ClientTitleGradientTo) t.Errorf("client_title_gradient: got from=%q to=%q", loaded.ClientTitleGradientFrom, loaded.ClientTitleGradientTo)
} }
} }
// Reproduit exactement le flux réel de l'admin : PUT /settings avec le JSON
// tel qu'envoyé par le frontend (category_configs[].products, en mode
// sélection), puis GET /settings pour vérifier ce qui revient — contrairement
// aux autres tests de ce fichier qui appellent testDB.UpdateSettings /
// GetSettings directement en Go, en contournant le binding JSON HTTP réel.
func TestUpdateSettingsHTTP_CategoryConfigProductsSurviveSaveReload(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.PointsReward = &models.PointsReward{
Threshold: 20,
CategoryConfigs: []models.RewardCategoryConfig{
{
Category: "test",
Type: "free_product",
AllProducts: false,
Products: []models.RewardProductQuantity{
{ProductID: 111, Quantity: 2},
{ProductID: 222, Quantity: 1},
},
},
},
}
body, err := json.Marshal(s)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
putReq := httptest.NewRequest(http.MethodPut, "/api/v2/admin/protected/settings", bytes.NewReader(body))
putReq.Header.Set("Content-Type", "application/json")
putRec := httptest.NewRecorder()
putCtx, _ := gin.CreateTestContext(putRec)
putCtx.Request = putReq
putCtx.Set("database", testDB)
handlers.UpdateSettings(putCtx)
if putRec.Code != http.StatusOK {
t.Fatalf("PUT /settings: status=%d body=%s", putRec.Code, putRec.Body.String())
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v2/admin/protected/settings", nil)
getRec := httptest.NewRecorder()
getCtx, _ := gin.CreateTestContext(getRec)
getCtx.Request = getReq
getCtx.Set("database", testDB)
handlers.GetSettings(getCtx)
if getRec.Code != http.StatusOK {
t.Fatalf("GET /settings: status=%d body=%s", getRec.Code, getRec.Body.String())
}
var resp struct {
Settings models.AppSettings `json:"settings"`
}
if err := json.Unmarshal(getRec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse GET: %v body=%s", err, getRec.Body.String())
}
if resp.Settings.PointsReward == nil {
t.Fatalf("points_reward est nil après reload")
}
if len(resp.Settings.PointsReward.CategoryConfigs) != 1 {
t.Fatalf("category_configs: got=%d want=1: %+v", len(resp.Settings.PointsReward.CategoryConfigs), resp.Settings.PointsReward.CategoryConfigs)
}
cfg := resp.Settings.PointsReward.CategoryConfigs[0]
if len(cfg.Products) != 2 {
t.Fatalf("products: got=%d want=2 (produits sélectionnés non persistés): %+v", len(cfg.Products), cfg.Products)
}
if cfg.Products[0].ProductID != 111 || cfg.Products[0].Quantity != 2 {
t.Errorf("products[0]: got=%+v want={ProductID:111 Quantity:2}", cfg.Products[0])
}
if cfg.Products[1].ProductID != 222 || cfg.Products[1].Quantity != 1 {
t.Errorf("products[1]: got=%+v want={ProductID:222 Quantity:1}", cfg.Products[1])
}
}
+15
View File
@@ -1126,6 +1126,19 @@ export interface PointsReward {
category_configs: RewardCategoryConfig[]; category_configs: RewardCategoryConfig[];
} }
export interface PromotionProductQuantity {
product_id: number;
quantity: number; // quantité individuelle de ce produit (palier de prix catalogue)
}
export interface CategoryPromotionConfig {
category: string;
discount_percent: number; // pourcentage de réduction libre (ex: 10, 20, 33.5)
all_products: boolean;
quantity: number; // quantité uniforme si all_products = true
products: PromotionProductQuantity[]; // produits + quantité individuelle si all_products = false
}
export interface PointsPool { export interface PointsPool {
key: string; key: string;
name: string; name: string;
@@ -1225,6 +1238,8 @@ export interface AppSettings {
points_enabled: boolean; points_enabled: boolean;
points_pools: PointsPool[]; points_pools: PointsPool[];
points_reward?: PointsReward | null; points_reward?: PointsReward | null;
promotions_enabled: boolean;
promotions: CategoryPromotionConfig[];
referral_enabled: boolean; referral_enabled: boolean;
delivery_schedule: DeliverySchedule; delivery_schedule: DeliverySchedule;
postal_zones: PostalZone[]; postal_zones: PostalZone[];
@@ -17,7 +17,7 @@ import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme"; import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext"; import { useTheme } from "../../context/ThemeContext";
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin"; import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin"; import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, CategoryPromotionConfig, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
import type { Product } from "../../api/types"; import type { Product } from "../../api/types";
import AlertModal from "../../components/ui/AlertModal"; import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert"; import { useAlert } from "../../hooks/useAlert";
@@ -1123,6 +1123,390 @@ function CentralRewardSection({
); );
} }
// ──────────────────────────────────────────────────────────────
// Sélecteur de produits pour une catégorie dans une promotion —
// même logique que CategoryProductPicker (récompenses), sans notion
// de "type" : une seule réduction (%) par catégorie.
// ──────────────────────────────────────────────────────────────
const PROMO_ACCENT = "#22c55e";
function PromotionProductPicker({
catConfig,
products,
onChange,
colors,
s,
}: {
catConfig: CategoryPromotionConfig;
products: Product[];
onChange: (cfg: CategoryPromotionConfig) => void;
colors: any;
s: any;
}) {
const catProducts = products.filter((p) => p.category === catConfig.category);
const toggleProduct = (id: number) => {
const exists = catConfig.products.some((pq) => pq.product_id === id);
if (exists) {
onChange({ ...catConfig, products: catConfig.products.filter((pq) => pq.product_id !== id), all_products: false });
return;
}
// Présélectionne le premier palier de prix actif du produit.
const prod = catProducts.find((p) => p.id === id);
const firstTier = (prod?.prices ?? []).find((pr) => pr.active_price !== false);
onChange({
...catConfig,
products: [...catConfig.products, { product_id: id, quantity: firstTier?.quantity ?? 0 }],
all_products: false,
});
};
const updateProductQuantity = (id: number, quantity: number) => {
onChange({
...catConfig,
products: catConfig.products.map((pq) =>
pq.product_id === id ? { ...pq, quantity } : pq
),
});
};
return (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
{/* Toggle tous / sélection */}
<View style={{ flexDirection: "row", gap: spacing.s }}>
<TouchableOpacity
onPress={() => onChange({ ...catConfig, all_products: true, products: [] })}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: catConfig.all_products ? PROMO_ACCENT : colors.border,
backgroundColor: catConfig.all_products ? PROMO_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="checkmark-done-outline" size={13} color={catConfig.all_products ? PROMO_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: catConfig.all_products ? "700" : "400", color: catConfig.all_products ? PROMO_ACCENT : colors.textMuted }}>
Tous ({catProducts.length})
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onChange({ ...catConfig, all_products: false })}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: !catConfig.all_products ? PROMO_ACCENT : colors.border,
backgroundColor: !catConfig.all_products ? PROMO_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="list-outline" size={13} color={!catConfig.all_products ? PROMO_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: !catConfig.all_products ? "700" : "400", color: !catConfig.all_products ? PROMO_ACCENT : colors.textMuted }}>
Sélection
</Text>
</TouchableOpacity>
</View>
{/* Mode "Tous" : une quantité uniforme pour tous les produits de la catégorie */}
{catConfig.all_products && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Quantité :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={catConfig.quantity > 0 ? String(catConfig.quantity) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
onChange({ ...catConfig, quantity: isNaN(n) ? 0 : n });
}}
placeholder="1"
placeholderTextColor={colors.textMuted}
/>
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
doit correspondre à un palier de prix existant
</Text>
</View>
)}
{/* Mode "Sélection" : chaque produit choisi a sa propre quantité,
via les paliers de prix réels du produit (pas de saisie libre) */}
{!catConfig.all_products && (
<View style={{ gap: spacing.xs }}>
{catProducts.length === 0 ? (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
Aucun produit dans cette catégorie
</Text>
) : (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{catProducts.map((p) => {
const sel = catConfig.products.some((pq) => pq.product_id === p.id);
return (
<TouchableOpacity
key={p.id}
onPress={() => toggleProduct(p.id)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: sel ? PROMO_ACCENT : colors.border,
backgroundColor: sel ? PROMO_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{sel && <Ionicons name="checkmark" size={11} color={PROMO_ACCENT} />}
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? PROMO_ACCENT : colors.textMuted }}>
{p.name}
</Text>
</TouchableOpacity>
);
})}
</View>
)}
{catConfig.products.length > 0 && (
<View style={{ gap: spacing.s, marginTop: spacing.xs }}>
{catConfig.products.map((pq) => {
const prod = catProducts.find((p) => p.id === pq.product_id);
const tiers = (prod?.prices ?? []).filter((pr) => pr.active_price !== false);
return (
<View key={pq.product_id} style={{ gap: 4 }}>
<Text style={{ fontSize: 12, color: colors.textMuted }} numberOfLines={1}>
{prod?.name ?? `Produit #${pq.product_id}`}
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4 }}>
{tiers.length === 0 ? (
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
Aucun palier de prix actif pour ce produit
</Text>
) : tiers.map((tier) => {
const isSel = pq.quantity === tier.quantity;
return (
<TouchableOpacity
key={tier.quantity}
onPress={() => updateProductQuantity(pq.product_id, tier.quantity)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 3,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: isSel ? PROMO_ACCENT : colors.border,
backgroundColor: isSel ? PROMO_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{isSel && <Ionicons name="checkmark" size={10} color={PROMO_ACCENT} />}
<Text style={{ fontSize: 11, fontWeight: isSel ? "700" : "400", color: isSel ? PROMO_ACCENT : colors.textMuted }}>
{tier.quantity}{prod?.unit ?? ""} · {tier.price}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
})}
</View>
)}
</View>
)}
</View>
);
}
// ──────────────────────────────────────────────────────────────
// Section centralisée promotions — réduction (%) automatique sur des
// produits/quantités d'une catégorie, appliquée à toute commande
// (indépendant des points de fidélité, contrairement aux récompenses).
// ──────────────────────────────────────────────────────────────
function PromotionsSection({
enabled,
promotions,
allCategories,
productsByCategory,
onToggle,
onChangePromotions,
colors,
s,
}: {
enabled: boolean;
promotions: CategoryPromotionConfig[];
allCategories: Category[];
productsByCategory: Record<string, Product[]>;
onToggle: (v: boolean) => void;
onChangePromotions: (promotions: CategoryPromotionConfig[]) => void;
colors: any;
s: any;
}) {
const getCatConfig = (catName: string): CategoryPromotionConfig =>
promotions.find((p) => p.category === catName) ??
{ category: catName, discount_percent: 10, all_products: true, quantity: 1, products: [] };
const isCatSelected = (catName: string) => promotions.some((p) => p.category === catName);
const toggleCategory = (catName: string) => {
if (isCatSelected(catName)) {
onChangePromotions(promotions.filter((p) => p.category !== catName));
} else {
onChangePromotions([...promotions, { category: catName, discount_percent: 10, all_products: true, quantity: 1, products: [] }]);
}
};
const updateCatConfig = (cfg: CategoryPromotionConfig) => {
onChangePromotions(promotions.map((p) => (p.category === cfg.category ? cfg : p)));
};
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set());
const toggleExpanded = (catName: string) => {
setExpandedCats((prev) => {
const next = new Set(prev);
if (next.has(catName)) next.delete(catName); else next.add(catName);
return next;
});
};
const promoBadge = (
<View style={{
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
backgroundColor: enabled ? PROMO_ACCENT + "25" : colors.border + "40",
borderWidth: 1, borderColor: enabled ? PROMO_ACCENT : colors.border,
}}>
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? PROMO_ACCENT : colors.textMuted }}>
{enabled ? "Activées" : "Désactivées"}
</Text>
</View>
);
return (
<AccordionSection title="Promotions" badge={promoBadge} colors={colors} s={s}>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Promotions activées</Text>
<Text style={s.rowDesc}>
Réduction automatique appliquée au prix affiché et facturé, pour tout client indépendant des points de fidélité.
</Text>
</View>
<Switch
value={enabled}
onValueChange={onToggle}
trackColor={{ false: colors.border, true: PROMO_ACCENT }}
thumbColor="#fff"
/>
</View>
{enabled && (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.l, gap: spacing.l }}>
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories en promo</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
Sélectionnez une catégorie, définissez le pourcentage de réduction, puis tous les produits ou une sélection avec leur quantité.
</Text>
{allCategories.length === 0 ? (
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
) : (
<View style={{ gap: spacing.m }}>
{allCategories.map((cat) => {
const selected = isCatSelected(cat.name);
const expanded = expandedCats.has(cat.name);
const catColor = cat.color || PROMO_ACCENT;
const cfg = getCatConfig(cat.name);
return (
<View key={cat.name}>
<TouchableOpacity
onPress={() => toggleExpanded(cat.name)}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
alignSelf: "flex-start",
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: selected ? catColor : colors.border,
backgroundColor: selected ? catColor + "22" : "transparent",
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: catColor }} />
<Text style={{ fontSize: 13, fontWeight: selected ? "700" : "400", color: selected ? catColor : colors.textMuted }}>
{cat.name}{selected ? ` · -${cfg.discount_percent}%` : ""}
</Text>
<Ionicons
name={expanded ? "chevron-down" : "chevron-forward"}
size={12}
color={selected ? catColor : colors.textMuted}
/>
</TouchableOpacity>
{expanded && (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
<TouchableOpacity
onPress={() => toggleCategory(cat.name)}
style={{
flexDirection: "row", alignItems: "center", gap: 4,
alignSelf: "flex-start",
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: selected ? PROMO_ACCENT : colors.border,
backgroundColor: selected ? PROMO_ACCENT + "22" : "transparent",
}}
>
<Ionicons
name={selected ? "checkbox" : "square-outline"}
size={14}
color={selected ? PROMO_ACCENT : colors.textMuted}
/>
<Ionicons name="pricetag-outline" size={12} color={selected ? PROMO_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: selected ? "700" : "400", color: selected ? PROMO_ACCENT : colors.textMuted }}>
Promo active sur cette catégorie
</Text>
</TouchableOpacity>
{selected && (
<>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Réduction :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={cfg.discount_percent > 0 ? String(cfg.discount_percent) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
updateCatConfig({ ...cfg, discount_percent: isNaN(n) ? 0 : n });
}}
placeholder="10"
placeholderTextColor={colors.textMuted}
/>
<Text style={{ fontSize: 12, color: colors.textMuted }}>%</Text>
</View>
<PromotionProductPicker
catConfig={cfg}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
</>
)}
</View>
)}
</View>
);
})}
</View>
)}
</View>
{/* Récapitulatif */}
{promotions.length > 0 && (
<View style={{ backgroundColor: PROMO_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: PROMO_ACCENT, padding: spacing.m, gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "700", color: PROMO_ACCENT }}>Récapitulatif</Text>
{promotions.map((cfg, idx) => (
<Text key={`${cfg.category}-${idx}`} style={{ fontSize: 12, color: colors.textSecondary }}>
{cfg.category} -{cfg.discount_percent}% sur {cfg.all_products
? `tous les produits · qté ${cfg.quantity > 0 ? cfg.quantity : 1}`
: `${cfg.products.length} produit(s) : ${cfg.products.map((pq) => `#${pq.product_id}×${pq.quantity}`).join(", ")}`}
</Text>
))}
</View>
)}
</View>
)}
</AccordionSection>
);
}
// Palette violette d'origine de l'application (thème par défaut historique) // Palette violette d'origine de l'application (thème par défaut historique)
const ORIGINAL_THEME_COLORS = { const ORIGINAL_THEME_COLORS = {
admin_color_primary: "#7c3aed", admin_color_primary: "#7c3aed",
@@ -1178,6 +1562,8 @@ export default function SettingsScreen() {
shop_name: "Milieu-Nantais", shop_name: "Milieu-Nantais",
contact_telegram: "", contact_telegram: "",
points_reward: null, points_reward: null,
promotions_enabled: false,
promotions: [],
admin_color_primary: "#7c3aed", admin_color_primary: "#7c3aed",
admin_color_secondary: "#22d3ee", admin_color_secondary: "#22d3ee",
admin_color_success: "#4ade80", admin_color_success: "#4ade80",
@@ -1257,6 +1643,11 @@ export default function SettingsScreen() {
})), })),
} }
: null, : null,
promotions_enabled: s.promotions_enabled ?? false,
promotions: (s.promotions ?? []).map((cfg) => ({
...cfg,
products: cfg.products ?? [],
})),
}); });
} }
if (categoriesRes) { if (categoriesRes) {
@@ -1872,6 +2263,18 @@ export default function SettingsScreen() {
s={s} s={s}
/> />
{/* Promotions — réduction automatique, indépendante des points */}
<PromotionsSection
enabled={settings.promotions_enabled ?? false}
promotions={settings.promotions ?? []}
allCategories={categories}
productsByCategory={productsByCategory}
onToggle={(v) => setSettings((p) => ({ ...p, promotions_enabled: v }))}
onChangePromotions={(promotions) => setSettings((p) => ({ ...p, promotions }))}
colors={colors}
s={s}
/>
{/* Horaires de livraison */} {/* Horaires de livraison */}
<DeliveryScheduleSection <DeliveryScheduleSection
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE} schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
+2
View File
@@ -358,6 +358,8 @@ export interface ProductPrice {
quantity: number; quantity: number;
price: number; price: number;
active_price?: boolean; active_price?: boolean;
promo_price?: number; // prix réduit si une promotion couvre ce palier
promo_percent?: number; // pourcentage de réduction appliqué
} }
export interface Product { export interface Product {
id: number; id: number;
+41 -11
View File
@@ -106,10 +106,17 @@ function ProductDetail() {
quantity: number; quantity: number;
price: number; price: number;
active_price?: boolean; active_price?: boolean;
promo_price?: number;
promo_percent?: number;
}) => ({ }) => ({
quantity: parseFloat(String(p.quantity)), quantity: parseFloat(String(p.quantity)),
price: parseFloat(String(p.price)), price: parseFloat(String(p.price)),
active_price: p.active_price, active_price: p.active_price,
promo_price:
p.promo_price != null
? parseFloat(String(p.promo_price))
: undefined,
promo_percent: p.promo_percent,
}), }),
) || [], ) || [],
}; };
@@ -118,8 +125,9 @@ function ProductDetail() {
// initialise le prix par défaut (float) // initialise le prix par défaut (float)
if (fixedProduct.prices.length > 0) { if (fixedProduct.prices.length > 0) {
setSelectedGrams(fixedProduct.prices[0].quantity); const first = fixedProduct.prices[0];
setSelectedPrice(fixedProduct.prices[0].price); setSelectedGrams(first.quantity);
setSelectedPrice(first.promo_price ?? first.price);
} }
// Couleur de la catégorie depuis la DB // Couleur de la catégorie depuis la DB
@@ -152,7 +160,11 @@ function ProductDetail() {
); );
if (priceOption) { if (priceOption) {
setSelectedPrice(parseFloat(String(priceOption.price))); setSelectedPrice(
priceOption.promo_price != null
? parseFloat(String(priceOption.promo_price))
: parseFloat(String(priceOption.price)),
);
} }
}; };
@@ -301,13 +313,29 @@ function ProductDetail() {
<div className="product-info-section"> <div className="product-info-section">
<h1 className="product-detail-name">{product.name}</h1> <h1 className="product-detail-name">{product.name}</h1>
{selectedPrice > 0 && ( {selectedPrice > 0 && (() => {
<p className="product-detail-price"> const selectedTier = product.prices?.find(
{selectedPrice.toFixed(2)} {" "} (p) => p.quantity === selectedGrams,
{selectedGrams && );
`pour ${selectedGrams}${product.unit || "g"}`} const hasPromo =
</p> selectedTier?.promo_price != null &&
)} selectedTier.promo_price < selectedTier.price;
return (
<p className="product-detail-price">
{hasPromo && (
<span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8 }}>
{selectedTier!.price.toFixed(2)}
</span>
)}
<span style={hasPromo ? { color: "#22c55e" } : undefined}>
{selectedPrice.toFixed(2)}
</span>{" "}
{selectedGrams &&
`pour ${selectedGrams}${product.unit || "g"}`}
{hasPromo && ` (-${selectedTier!.promo_percent}%)`}
</p>
);
})()}
<div className="product-description"> <div className="product-description">
<h3>Description</h3> <h3>Description</h3>
@@ -345,7 +373,9 @@ function ProductDetail() {
> >
{p.quantity} {p.quantity}
{product.unit || "g"} -{" "} {product.unit || "g"} -{" "}
{p.price.toFixed(2)} {p.promo_price != null && p.promo_price < p.price
? `${p.promo_price.toFixed(2)} € (au lieu de ${p.price.toFixed(2)} €, -${p.promo_percent}%)`
: `${p.price.toFixed(2)}`}
</option> </option>
))} ))}
</select> </select>
+2
View File
@@ -355,6 +355,8 @@ export interface ProductPrice {
quantity: number; quantity: number;
price: number; price: number;
active_price?: boolean; active_price?: boolean;
promo_price?: number; // prix réduit si une promotion couvre ce palier
promo_percent?: number; // pourcentage de réduction appliqué
} }
export interface Product { export interface Product {
id: number; id: number;
@@ -63,12 +63,18 @@ export default function ProductDetailScreen() {
quantity: parseFloat(String(pr.quantity)), quantity: parseFloat(String(pr.quantity)),
price: parseFloat(String(pr.price)), price: parseFloat(String(pr.price)),
active_price: pr.active_price, active_price: pr.active_price,
promo_price:
pr.promo_price != null
? parseFloat(String(pr.promo_price))
: undefined,
promo_percent: pr.promo_percent,
})) || [], })) || [],
}; };
setProduct(fixedProduct); setProduct(fixedProduct);
if (fixedProduct.prices.length > 0) { if (fixedProduct.prices.length > 0) {
setSelectedGrams(fixedProduct.prices[0].quantity); const first = fixedProduct.prices[0];
setSelectedPrice(fixedProduct.prices[0].price); setSelectedGrams(first.quantity);
setSelectedPrice(first.promo_price ?? first.price);
} }
const matched = categories.find( const matched = categories.find(
(c) => (c) =>
@@ -90,7 +96,7 @@ export default function ProductDetailScreen() {
const handleGramsChange = (quantity: number) => { const handleGramsChange = (quantity: number) => {
setSelectedGrams(quantity); setSelectedGrams(quantity);
const opt = product?.prices?.find((p) => p.quantity === quantity); const opt = product?.prices?.find((p) => p.quantity === quantity);
if (opt) setSelectedPrice(opt.price); if (opt) setSelectedPrice(opt.promo_price ?? opt.price);
setShowQuantityPicker(false); setShowQuantityPicker(false);
}; };
@@ -585,16 +591,45 @@ export default function ProductDetailScreen() {
<View style={styles.infoSection}> <View style={styles.infoSection}>
<Text style={styles.productName}>{product.name}</Text> <Text style={styles.productName}>{product.name}</Text>
{selectedPrice > 0 && ( {selectedPrice > 0 && (() => {
<View style={styles.priceRow}> const selectedTier = product.prices?.find(
<View style={styles.priceIndicator} /> (p) => p.quantity === selectedGrams,
<Text style={styles.priceText}> );
{selectedPrice.toFixed(2)} {" "} const hasPromo =
{selectedGrams && selectedTier?.promo_price != null &&
`pour ${selectedGrams}${product.unit || "g"}`} selectedTier.promo_price < selectedTier.price;
</Text> return (
</View> <View style={styles.priceRow}>
)} <View style={styles.priceIndicator} />
{hasPromo && (
<Text
style={[
styles.priceText,
{
textDecorationLine: "line-through",
opacity: 0.6,
marginRight: 6,
},
]}
>
{selectedTier!.price.toFixed(2)}
</Text>
)}
<Text
style={[
styles.priceText,
hasPromo && { color: "#22c55e" },
]}
>
{selectedPrice.toFixed(2)} {" "}
{selectedGrams &&
`pour ${selectedGrams}${product.unit || "g"}`}
{hasPromo &&
` (-${selectedTier!.promo_percent}%)`}
</Text>
</View>
);
})()}
<View style={styles.descriptionCard}> <View style={styles.descriptionCard}>
<Text style={styles.descriptionTitle}>Description</Text> <Text style={styles.descriptionTitle}>Description</Text>
<Text style={styles.descriptionText}> <Text style={styles.descriptionText}>
@@ -719,16 +754,32 @@ export default function ProductDetailScreen() {
{p.quantity} {p.quantity}
{product.unit || "g"} {product.unit || "g"}
</Text> </Text>
<Text {p.promo_price != null && p.promo_price < p.price ? (
style={[ <View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
styles.pickerOptionPrice, <Text
selectedGrams === p.quantity && { style={[
color: catColor, styles.pickerOptionPrice,
}, { textDecorationLine: "line-through", opacity: 0.6 },
]} ]}
> >
{p.price.toFixed(2)} {p.price.toFixed(2)}
</Text> </Text>
<Text style={[styles.pickerOptionPrice, { color: "#22c55e" }]}>
{p.promo_price.toFixed(2)} (-{p.promo_percent}%)
</Text>
</View>
) : (
<Text
style={[
styles.pickerOptionPrice,
selectedGrams === p.quantity && {
color: catColor,
},
]}
>
{p.price.toFixed(2)}
</Text>
)}
</View> </View>
{selectedGrams === p.quantity && ( {selectedGrams === p.quantity && (
<View <View