diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go index d11f119b..30b7c65d 100644 --- a/backend/gestion/db/db_basket.go +++ b/backend/gestion/db/db_basket.go @@ -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) { var basket models.Panier err := d.GDB.Transaction(func(tx *gorm.DB) error { - var currentStock float64 - if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, productID).Scan(¤tStock).Error; err != nil { + var productInfo struct { + 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) } - if currentStock < quantity { + if productInfo.Stock < quantity { 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 { 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 { ID int `gorm:"column:id"` diff --git a/backend/gestion/db/db_promotions.go b/backend/gestion/db/db_promotions.go new file mode 100644 index 00000000..b1eccf24 --- /dev/null +++ b/backend/gestion/db/db_promotions.go @@ -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 +} diff --git a/backend/gestion/db/db_settings.go b/backend/gestion/db/db_settings.go index 1affe22a..a476a9ab 100644 --- a/backend/gestion/db/db_settings.go +++ b/backend/gestion/db/db_settings.go @@ -72,19 +72,19 @@ func DefaultSettings() models.AppSettings { Mode: "single", CategoryRoutes: []models.CategoryRoute{}, }, - AdminColorPrimary: "#7c3aed", - AdminColorSecondary: "#000000", - AdminColorSuccess: "#4ade80", - AdminColorDanger: "#ef4444", - AdminColorWarning: "#f59e0b", - ClientColorPrimary: "#7c3aed", - ClientColorSecondary: "#000000", - ClientColorSuccess: "#4ade80", - ClientColorDanger: "#ef4444", - ClientColorWarning: "#f59e0b", + AdminColorPrimary: "#7c3aed", + AdminColorSecondary: "#000000", + AdminColorSuccess: "#4ade80", + AdminColorDanger: "#ef4444", + AdminColorWarning: "#f59e0b", + ClientColorPrimary: "#7c3aed", + ClientColorSecondary: "#000000", + ClientColorSuccess: "#4ade80", + ClientColorDanger: "#ef4444", + ClientColorWarning: "#f59e0b", ClientTitleGradientFrom: "#a78bfa", ClientTitleGradientTo: "#22d3ee", - DeliverySchedule: DefaultDeliverySchedule(), + DeliverySchedule: DefaultDeliverySchedule(), PostalZones: []models.PostalZone{ {Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}}, {Name: "Zone 50€", MinAmount: 50, Codes: []string{ @@ -115,6 +115,11 @@ func (d *Database) GetSettings() (models.AppSettings, error) { switch row.Key { case "penalties_enabled": settings.PenaltiesEnabled = row.Value == "true" + case "penalty_tiers": + var tiers []models.PenaltyTier + if err := json.Unmarshal([]byte(row.Value), &tiers); err == nil { + settings.PenaltyTiers = tiers + } case "show_amende_score": settings.ShowAmendeScore = row.Value == "true" case "points_enabled": @@ -125,9 +130,24 @@ func (d *Database) GetSettings() (models.AppSettings, error) { settings.PointsPools = pools } case "points_reward": - var reward models.PointsReward - if err := json.Unmarshal([]byte(row.Value), &reward); err == nil { - settings.PointsReward = &reward + // row.Value peut valoir la chaîne littérale "null" (récompense + // désactivée puis sauvegardée : json.Marshal(nil *PointsReward) + // produit "null"). json.Unmarshal d'un null JSON dans une valeur + // non-pointeur est un no-op sans erreur (voir doc encoding/json), + // donc sans ce garde-fou &reward pointerait vers une struct vide + // mais non-nil, et la récompense réapparaîtrait activée. + if row.Value != "null" && row.Value != "" { + var reward models.PointsReward + if err := json.Unmarshal([]byte(row.Value), &reward); err == nil { + 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": settings.ReferralEnabled = row.Value == "true" @@ -213,6 +233,14 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { return "false" } + if s.PenaltyTiers == nil { + s.PenaltyTiers = []models.PenaltyTier{} + } + tiersJSON, err := json.Marshal(s.PenaltyTiers) + if err != nil { + return fmt.Errorf("erreur sérialisation penalty_tiers: %w", err) + } + if s.PointsPools == nil { s.PointsPools = []models.PointsPool{} } @@ -230,11 +258,31 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { return fmt.Errorf("erreur sérialisation pools: %w", err) } + if s.PointsReward != nil { + for i := range s.PointsReward.CategoryConfigs { + if s.PointsReward.CategoryConfigs[i].Products == nil { + s.PointsReward.CategoryConfigs[i].Products = []models.RewardProductQuantity{} + } + } + } rewardJSON, err := json.Marshal(s.PointsReward) if err != nil { 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 { s.NowPaymentsCurrencies = []string{} } @@ -269,10 +317,13 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { } pairs := [][2]string{ {"penalties_enabled", boolStr(s.PenaltiesEnabled)}, + {"penalty_tiers", string(tiersJSON)}, {"show_amende_score", boolStr(s.ShowAmendeScore)}, {"points_enabled", boolStr(s.PointsEnabled)}, {"points_pools", string(poolsJSON)}, {"points_reward", string(rewardJSON)}, + {"promotions_enabled", boolStr(s.PromotionsEnabled)}, + {"promotions", string(promotionsJSON)}, {"referral_enabled", boolStr(s.ReferralEnabled)}, {"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)}, {"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)}, diff --git a/backend/gestion/handlers/points.go b/backend/gestion/handlers/points.go index a97aa39b..1b7d63ad 100644 --- a/backend/gestion/handlers/points.go +++ b/backend/gestion/handlers/points.go @@ -23,72 +23,79 @@ func normalizeRewardCategoryType(t string) string { return "free_product" } -// eligibleRewardProducts détermine, pour un pool donné, quels product_id de -// reward.RewardItems sont éligibles et avec quel type de récompense -// ("free_product" | "half_price_product") : sa catégorie (via CategoryConfigs) -// doit faire partie des catégories du pool, soit par whitelist explicite -// (ProductIDs) soit par correspondance de catégorie produit (AllProducts). -func eligibleRewardProducts(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]string { - eligible := make(map[int]string) +// categoryRewardCandidate représente un produit éligible à la récompense pour +// une config de catégorie donnée : son type ("free_product" | +// "half_price_product") et la quantité configurée pour cette catégorie. +type categoryRewardCandidate struct { + Category string + Type string + ProductID int + Name string + Quantity float64 +} + +// resolveCategoryRewardCandidates dérive, pour chaque config de catégorie de +// la récompense, la liste des produits éligibles — tous ceux du catalogue si +// AllProducts, sinon la sélection explicite — avec le type et la quantité +// configurés directement dans le bloc catégorie (RewardCategoryConfig). +// Il n'existe plus de liste "reward_items" saisie à part : la catégorie est +// l'unique source de vérité (type + produits + quantité). +func resolveCategoryRewardCandidates(database *db.Database, reward *models.PointsReward) ([]categoryRewardCandidate, error) { + candidates := make([]categoryRewardCandidate, 0) if reward == nil { - return eligible + return candidates, nil } + + catalogCache := make(map[string][]models.Product) for _, cfg := range reward.CategoryConfigs { - if !poolCategories[cfg.Category] { - continue - } rewardType := normalizeRewardCategoryType(cfg.Type) if cfg.AllProducts { - for pid, cat := range productCategories { - if cat == cfg.Category { - eligible[pid] = rewardType + products, ok := catalogCache[cfg.Category] + if !ok { + var err error + products, err = database.GetProductsByCategory(cfg.Category) + if err != nil { + return nil, fmt.Errorf("produits catégorie %q: %w", cfg.Category, err) } + catalogCache[cfg.Category] = products } - } else { - for _, pid := range cfg.ProductIDs { - eligible[pid] = rewardType + for _, p := range products { + candidates = append(candidates, categoryRewardCandidate{ + Category: cfg.Category, Type: rewardType, ProductID: p.ID, Name: p.Name, Quantity: cfg.Quantity, + }) + } + } else if len(cfg.Products) > 0 { + ids := make([]int, len(cfg.Products)) + for i, pq := range cfg.Products { + ids[i] = pq.ProductID + } + names, err := database.GetProductNamesByIDs(ids) + if err != nil { + return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err) + } + for _, pq := range cfg.Products { + candidates = append(candidates, categoryRewardCandidate{ + Category: cfg.Category, Type: rewardType, ProductID: pq.ProductID, Name: names[pq.ProductID], Quantity: pq.Quantity, + }) } } } - return eligible + return candidates, nil } -// categoryConfigTypeForProduct détermine le type de récompense applicable à un -// produit à partir de sa catégorie catalogue, sans filtrer par pool — utilisé -// pour l'aperçu global (rewardMeta) qui n'est pas rattaché à un pool précis. -func categoryConfigTypeForProduct(reward *models.PointsReward, productID int, productCategory string) string { - for _, cfg := range reward.CategoryConfigs { - matches := false - if cfg.AllProducts { - matches = cfg.Category == productCategory - } else { - for _, pid := range cfg.ProductIDs { - if pid == productID { - matches = true - break - } - } - } - if matches { - return normalizeRewardCategoryType(cfg.Type) - } - } - return "free_product" -} - -// effectiveRewardPrice calcule le prix réellement facturé pour un item -// récompense selon le type de sa catégorie : 0€ pour "free_product", 50% du -// prix catalogue actif (palier correspondant à la quantité) pour +// effectiveRewardPrice calcule le prix réellement facturé pour une quantité +// donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour +// "free_product", 50% du prix catalogue actif (palier ≤ quantity) pour // "half_price_product". Erreur si le prix catalogue est introuvable (produit // désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas // être proposée/réclamée plutôt que de facturer un montant incorrect. -func effectiveRewardPrice(database *db.Database, item models.RewardItem, rewardType string) (float64, error) { +func effectiveRewardPrice(database *db.Database, productID int, quantity float64, rewardType string) (float64, error) { if rewardType != "half_price_product" { return 0, nil } - catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity) + catalogPrice, err := database.GetActiveProductPrice(productID, quantity) if err != nil { - return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", item.ProductID, err) + return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", productID, err) } return math.Round(catalogPrice/2*100) / 100, nil } @@ -123,12 +130,18 @@ func GetMyPointsRewards(c *gin.Context) { reward := settings.PointsReward + type ConfigProductResponse struct { + ProductID int `json:"product_id"` + ProductName string `json:"product_name"` + Quantity float64 `json:"quantity"` + } + type EligibleConfigResponse struct { - Category string `json:"category"` - Type string `json:"type"` - AllProducts bool `json:"all_products"` - ProductIDs []int `json:"product_ids"` - ProductNames []string `json:"product_names"` + Category string `json:"category"` + Type string `json:"type"` + AllProducts bool `json:"all_products"` + Products []ConfigProductResponse `json:"products"` + Quantity float64 `json:"quantity"` } type RewardItemResponse struct { @@ -150,22 +163,10 @@ func GetMyPointsRewards(c *gin.Context) { EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"` } - // Collecter tous les product_ids nécessaires en un seul passage - allProductIDs := make([]int, 0) - if reward != nil { - for _, cfg := range reward.CategoryConfigs { - if !cfg.AllProducts { - allProductIDs = append(allProductIDs, cfg.ProductIDs...) - } - } - for _, item := range reward.RewardItems { - if item.ProductID > 0 { - allProductIDs = append(allProductIDs, item.ProductID) - } - } + candidates, err := resolveCategoryRewardCandidates(database, reward) + if err != nil { + log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err) } - productNames, _ := database.GetProductNamesByIDs(allProductIDs) - productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs) pools := make([]PoolInfo, 0, len(settings.PointsPools)) for _, pool := range settings.PointsPools { @@ -189,43 +190,48 @@ func GetMyPointsRewards(c *gin.Context) { if !poolCats[cfg.Category] { continue } - names := make([]string, 0, len(cfg.ProductIDs)) - for _, pid := range cfg.ProductIDs { - if n, ok := productNames[pid]; ok { - names = append(names, n) + products := make([]ConfigProductResponse, 0, len(cfg.Products)) + for _, pq := range cfg.Products { + name := "" + for _, cand := range candidates { + if cand.ProductID == pq.ProductID && cand.Category == cfg.Category { + name = cand.Name + break + } } + products = append(products, ConfigProductResponse{ + ProductID: pq.ProductID, + ProductName: name, + Quantity: pq.Quantity, + }) } eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{ - Category: cfg.Category, - Type: normalizeRewardCategoryType(cfg.Type), - AllProducts: cfg.AllProducts, - ProductIDs: cfg.ProductIDs, - ProductNames: names, + Category: cfg.Category, + Type: normalizeRewardCategoryType(cfg.Type), + AllProducts: cfg.AllProducts, + Products: products, + Quantity: cfg.Quantity, }) } } - eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories) eligibleRewardItems := make([]RewardItemResponse, 0) - if reward != nil { - for _, item := range reward.RewardItems { - rewardType, ok := eligibleProducts[item.ProductID] - if !ok { - continue - } - price, err := effectiveRewardPrice(database, item, rewardType) - if err != nil { - log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err) - continue - } - eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{ - ProductID: item.ProductID, - ProductName: productNames[item.ProductID], - Quantity: item.Quantity, - Price: price, - Type: rewardType, - }) + for _, cand := range candidates { + if !poolCats[cand.Category] { + continue } + price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type) + if err != nil { + log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err) + continue + } + eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{ + ProductID: cand.ProductID, + ProductName: cand.Name, + Quantity: cand.Quantity, + Price: price, + Type: cand.Type, + }) } pools = append(pools, PoolInfo{ @@ -240,28 +246,22 @@ func GetMyPointsRewards(c *gin.Context) { }) } - // Construire la liste des produits récompense avec leurs noms (aperçu - // global, indépendant d'un pool précis — le type/prix effectif par pool - // est celui exposé dans pools[].eligible_reward_items). + // Aperçu global des produits récompense, indépendant d'un pool précis — le + // type/prix effectif par pool est celui exposé dans pools[].eligible_reward_items. var rewardMeta gin.H if reward != nil { - rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems)) - for _, item := range reward.RewardItems { - if item.ProductID <= 0 { + rewardItems := make([]RewardItemResponse, 0, len(candidates)) + for _, cand := range candidates { + price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type) + if err != nil { continue } - rewardType := categoryConfigTypeForProduct(reward, item.ProductID, productCategories[item.ProductID]) - price, err := effectiveRewardPrice(database, item, rewardType) - if err != nil { - price = item.Price // fallback indicatif si le prix catalogue est momentanément indisponible - } - name := productNames[item.ProductID] rewardItems = append(rewardItems, RewardItemResponse{ - ProductID: item.ProductID, - ProductName: name, - Quantity: item.Quantity, + ProductID: cand.ProductID, + ProductName: cand.Name, + Quantity: cand.Quantity, Price: price, - Type: rewardType, + Type: cand.Type, }) } rewardMeta = gin.H{ @@ -324,46 +324,40 @@ func ClaimMyReward(c *gin.Context) { } // Un produit récompense n'est éligible pour ce pool que si sa catégorie - // fait partie des catégories du pool (via CategoryConfigs) — sans ce - // filtre, un client pourrait réclamer n'importe quel produit récompense - // (toutes catégories confondues) avec les points d'un pool quelconque. + // fait partie des catégories du pool — sans ce filtre, un client pourrait + // réclamer n'importe quel produit récompense (toutes catégories + // confondues) avec les points d'un pool quelconque. poolCategories := make(map[string]bool, len(selectedPool.Categories)) for _, cat := range selectedPool.Categories { poolCategories[cat] = true } - rewardProductIDs := make([]int, 0, len(reward.RewardItems)) - for _, item := range reward.RewardItems { - if item.ProductID > 0 { - rewardProductIDs = append(rewardProductIDs, item.ProductID) - } - } - productCategories, err := database.GetProductCategoriesByIDs(rewardProductIDs) + candidates, err := resolveCategoryRewardCandidates(database, reward) if err != nil { - utils.ServerErr(c, "Erreur lecture catégories produits", err) + utils.ServerErr(c, "Erreur résolution produits récompense", err) return } - eligibleProducts := eligibleRewardProducts(reward, poolCategories, productCategories) - // Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici, // avant toute écriture — si un item ne peut pas être tarifé (produit sans // palier de prix actif), la réclamation entière échoue proprement, avant // même de démarrer la transaction de consommation de points. - eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems)) - for _, item := range reward.RewardItems { - rewardType, ok := eligibleProducts[item.ProductID] - if !ok { + eligibleItems := make([]models.RewardItem, 0, len(candidates)) + for _, cand := range candidates { + if !poolCategories[cand.Category] { continue } - price, err := effectiveRewardPrice(database, item, rewardType) + price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type) if err != nil { log.Printf("❌ [CLAIM] %s: %v", username, err) c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"}) return } - item.Price = price - eligibleItems = append(eligibleItems, item) + eligibleItems = append(eligibleItems, models.RewardItem{ + ProductID: cand.ProductID, + Quantity: cand.Quantity, + Price: price, + }) } itemsToAdd := eligibleItems diff --git a/backend/gestion/handlers/product.go b/backend/gestion/handlers/product.go index 8cd984e3..b59601a5 100644 --- a/backend/gestion/handlers/product.go +++ b/backend/gestion/handlers/product.go @@ -8,6 +8,7 @@ import ( "gestion/utils" "io" "log" + "math" "mime/multipart" "net/http" "strconv" @@ -412,6 +413,7 @@ func GetAllProducts(c *gin.Context) { if role != "admin" && role != "cabine" { products = filterActivePrices(products) } + products = applyPromotions(products, database) c.JSON(http.StatusOK, gin.H{ "success": true, "data": products, @@ -450,6 +452,7 @@ func GetProductsByCategory(c *gin.Context) { if roleCtx != "admin" && roleCtx != "cabine" { products = filterActivePrices(products) } + products = applyPromotions(products, database) c.JSON(http.StatusOK, gin.H{ "success": true, "data": products, @@ -482,6 +485,7 @@ func GetProductByID(c *gin.Context) { if role != "admin" && role != "cabine" { filterActivepricesSingle(&product) } + applyPromotionsSingle(&product, database) c.JSON(http.StatusOK, gin.H{ "success": true, @@ -1002,3 +1006,32 @@ func filterActivepricesSingle(product *models.Product) { } 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] +} diff --git a/backend/gestion/models/product.go b/backend/gestion/models/product.go index 9225c778..1ad4b83d 100644 --- a/backend/gestion/models/product.go +++ b/backend/gestion/models/product.go @@ -32,6 +32,13 @@ type ProductPrice struct { // TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et // seulement source du bug. 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" } diff --git a/backend/gestion/models/settings.go b/backend/gestion/models/settings.go index be603ce4..f2330fc3 100644 --- a/backend/gestion/models/settings.go +++ b/backend/gestion/models/settings.go @@ -14,30 +14,76 @@ type PointsTier struct { Points int `json:"points"` } -// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense, -// ainsi que le type de récompense appliqué pour cette catégorie précise. -type RewardCategoryConfig struct { - Category string `json:"category"` // nom de la catégorie - Type string `json:"type"` // "free_product" (défaut) | "half_price_product" - AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie - ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false +// RewardProductQuantity associe un produit à sa propre quantité offerte / à +// -50%, pour le cas où une catégorie n'est pas configurée en "tous les +// produits" — ex: produit A à 2g offerts, produit B à 1g offert, tous deux +// dans la même catégorie et le même type de récompense. +type RewardProductQuantity struct { + ProductID int `json:"product_id"` + Quantity float64 `json:"quantity"` } -// RewardItem représente un produit offert lors d'une récompense, avec sa quantité et son prix associé +// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense, +// le type de récompense appliqué pour cette catégorie précise, et la quantité +// concernée (ex: 1g offert, ou 2g à -50%) — la quantité correspond au palier +// de prix catalogue du produit (voir GetActiveProductPrice), pas une valeur +// libre : ex. "30€ offert = 1g" si le produit a un palier quantity=1 à 30€. +// +// 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 RewardCategoryConfig struct { + Category string `json:"category"` // nom de la catégorie + Type string `json:"type"` // "free_product" (défaut) | "half_price_product" + AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie + Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true + Products []RewardProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false +} + +// RewardItem représente un produit résolu à ajouter au panier lors d'un +// claim (ProductID + Quantity + Price effectif) — construit dynamiquement à +// partir des CategoryConfigs au moment du claim, plus une liste saisie à part. type RewardItem struct { ProductID int `json:"product_id"` // ID du produit ajouté au panier Quantity float64 `json:"quantity"` // quantité offerte - Price float64 `json:"price"` // valeur indicative affichée au client + Price float64 `json:"price"` // prix effectif facturé (0 si offert, 50% du prix catalogue si -50%) } // PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés. -// Le type de récompense (gratuit ou -50%) n'est plus global : il est défini par catégorie -// dans CategoryConfigs (voir RewardCategoryConfig.Type). +// Le type de récompense (gratuit ou -50%) et la quantité concernée sont +// définis par catégorie dans CategoryConfigs (voir RewardCategoryConfig) — +// les produits éligibles et leur quantité ne sont plus saisis à part. type PointsReward struct { Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20) Description string `json:"description"` // description libre affichée au client - CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type par catégorie - RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim + 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 @@ -87,28 +133,30 @@ type DeliveryModeConfig struct { // AppSettings contient les paramètres globaux de l'application type AppSettings struct { - PenaltiesEnabled bool `json:"penalties_enabled"` - 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) - PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points - PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés - 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 - ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage - CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto - CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces) - NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments - NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments - NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"]) - DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour - PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande - TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison - TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @) - TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram - DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs - 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 + PenaltiesEnabled bool `json:"penalties_enabled"` + 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) + PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points + PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés + PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points + PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions + Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie + ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage + ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage + CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto + CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces) + NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments + NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments + NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"]) + DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour + PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande + TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison + TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @) + TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram + DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs + 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 AdminColorPrimary string `json:"admin_color_primary"` AdminColorSecondary string `json:"admin_color_secondary"` diff --git a/backend/gestion/tests/promotions_test.go b/backend/gestion/tests/promotions_test.go new file mode 100644 index 00000000..e9b80ab2 --- /dev/null +++ b/backend/gestion/tests/promotions_test.go @@ -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) + } +} diff --git a/backend/gestion/tests/rewards_handler_test.go b/backend/gestion/tests/rewards_handler_test.go index 32d3bc52..88ea1514 100644 --- a/backend/gestion/tests/rewards_handler_test.go +++ b/backend/gestion/tests/rewards_handler_test.go @@ -25,9 +25,9 @@ func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.R } // configureRewardSettings applique la récompense donnée, avec pool_0 mappé -// sur la catégorie "test" — nécessaire pour que eligibleRewardProducts +// sur la catégorie "test" — nécessaire pour que resolveCategoryRewardCandidates // (qui croise pool.Categories et reward.CategoryConfigs) considère les -// reward_items comme éligibles. +// produits de la catégorie comme éligibles. func configureRewardSettings(t *testing.T, reward *models.PointsReward) { t.Helper() settings := db.DefaultSettings() @@ -39,17 +39,20 @@ func configureRewardSettings(t *testing.T, reward *models.PointsReward) { } // Flux complet réel : POST /points/claim avec un seuil atteint doit ajouter -// le produit récompense configuré au panier et décompter la récompense. +// le produit récompense configuré au panier et décompter la récompense. Le +// produit éligible et sa quantité sont désormais définis directement dans le +// bloc catégorie (RewardCategoryConfig), plus de liste "reward_items" à part. func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_flow") rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5) configureRewardSettings(t, &models.PointsReward{ - Threshold: 20, - Description: "Un produit offert", - CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}}, - RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, + Threshold: 20, + Description: "Un produit offert", + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "free_product", AllProducts: true, Quantity: 1}, + }, }) setClientPoolPoints(t, username, "pool_0", 20) @@ -85,9 +88,9 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test } } -// Catégorie configurée en "half_price_product" : le produit récompense doit -// être ajouté au panier à 50% du prix catalogue actif (pas 0€, pas le prix -// indicatif RewardItem.Price saisi par l'admin). +// Catégorie configurée en "half_price_product" avec quantité=1 : le produit +// récompense doit être ajouté au panier à 50% du prix catalogue actif pour +// cette quantité (palier ≤ 1), pas 0€. func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_halfprice") @@ -95,10 +98,11 @@ func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPri // newTestProduct crée un prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go). configureRewardSettings(t, &models.PointsReward{ - Threshold: 20, - Description: "Un produit à moitié prix", - CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "half_price_product", AllProducts: true}}, - RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 999}}, // Price indicatif, doit être ignoré + Threshold: 20, + Description: "Un produit à moitié prix", + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "half_price_product", AllProducts: true, Quantity: 1}, + }, }) setClientPoolPoints(t, username, "pool_0", 20) @@ -119,15 +123,60 @@ func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPri } } +// La quantité configurée dans le bloc catégorie détermine le palier de prix +// utilisé pour le calcul du -50% (ex: 30€ le palier quantity=1 → 15€ facturé), +// pas un prix indicatif saisi ailleurs. +func TestClaimMyReward_HTTPFlow_HalfPriceUsesConfiguredQuantityForPriceTier(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_http_halfprice_qty") + rewardProductID := newTestProduct(t, "RewardHTTPHalfPriceQty", 20) + // Ajoute un palier quantity=3 à 30€ (en plus du palier quantity=1 à 10€ créé par newTestProduct). + if err := testDB.GDB.Exec( + `INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 3, 30.00, true)`, + rewardProductID, + ).Error; err != nil { + t.Fatalf("création palier de prix supplémentaire: %v", err) + } + + configureRewardSettings(t, &models.PointsReward{ + Threshold: 20, + Description: "Un produit à moitié prix, quantité 3", + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "half_price_product", AllProducts: true, Quantity: 3}, + }, + }) + setClientPoolPoints(t, username, "pool_0", 20) + + body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"}) + c, rec := claimRewardContext(username, body) + handlers.ClaimMyReward(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + rows := basketRewardItems(t, username) + if len(rows) != 1 || rows[0].ProductID != rewardProductID { + t.Fatalf("le produit récompense doit être dans le panier: %+v", rows) + } + if rows[0].Quantity != 3 { + t.Errorf("la quantité en panier doit être celle configurée pour la catégorie: got=%.2f want=3", rows[0].Quantity) + } + if rows[0].Price != 15.0 { + t.Errorf("palier quantity=3 à 30€ : prix attendu = 50%% = 15.00€: got=%.2f", rows[0].Price) + } +} + func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_below") - rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5) + newTestProduct(t, "RewardHTTPBelow", 5) configureRewardSettings(t, &models.PointsReward{ - Threshold: 20, - CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}}, - RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, + Threshold: 20, + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "free_product", AllProducts: true, Quantity: 1}, + }, }) setClientPoolPoints(t, username, "pool_0", 5) @@ -140,22 +189,21 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) { } } -// Si un item récompense configuré par l'admin pointe vers un produit -// supprimé/inexistant, la réclamation entière doit échouer — la récompense -// ne doit pas être consommée sans qu'aucun produit ne soit livré au client -// (ClaimPoolReward + AddRewardsToBasket sont maintenant dans la même -// transaction via ClaimPoolRewardAndAddToBasket). +// Si un produit configuré par l'admin (via Products explicite) pointe vers +// un produit supprimé/inexistant, la réclamation entière doit échouer — la +// récompense ne doit pas être consommée sans qu'aucun produit ne soit livré +// au client (ClaimPoolReward + AddRewardsToBasket sont dans la même +// transaction via ClaimPoolRewardAndAddToBasket ; la contrainte de clé +// étrangère sur baskets.product_id fait échouer l'insertion). func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_missing_product") configureRewardSettings(t, &models.PointsReward{ Threshold: 20, - // ProductIDs explicite (pas AllProducts) : le produit n'existe pas en - // base, donc il n'apparaîtrait jamais dans productCategories et ne - // serait jamais éligible via une correspondance AllProducts. - CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", ProductIDs: []int{999999999}}}, - RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{{ProductID: 999999999, Quantity: 1}}}, + }, }) setClientPoolPoints(t, username, "pool_0", 20) @@ -175,3 +223,114 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) t.Errorf("la récompense ne doit PAS être consommée si le produit est introuvable: got redeemed=%d want=0", redeemed["pool_0"]) } } + +// Une même catégorie peut avoir les deux types de récompense actifs en +// parallèle (un lot de produits offerts + un lot de produits à -50%), chacun +// avec sa propre sélection de produits et sa propre quantité. Un seul claim +// doit alors ajouter les deux produits au panier, chacun tarifé selon son +// propre type et sa propre quantité. +func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_http_dual_type") + freeProductID := newTestProduct(t, "RewardHTTPDualFree", 5) + halfProductID := newTestProduct(t, "RewardHTTPDualHalf", 5) + // newTestProduct crée les deux produits dans la catégorie "test", avec un + // prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go). + + configureRewardSettings(t, &models.PointsReward{ + Threshold: 20, + Description: "Un produit offert + un produit à -50%", + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{{ProductID: freeProductID, Quantity: 1}}}, + {Category: "test", Type: "half_price_product", Products: []models.RewardProductQuantity{{ProductID: halfProductID, Quantity: 1}}}, + }, + }) + setClientPoolPoints(t, username, "pool_0", 20) + + body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"}) + c, rec := claimRewardContext(username, body) + handlers.ClaimMyReward(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + rows := basketRewardItems(t, username) + if len(rows) != 2 { + t.Fatalf("les deux produits récompense doivent être dans le panier: %+v", rows) + } + + var freeRow, halfRow *rewardBasketRow + for i := range rows { + switch rows[i].ProductID { + case freeProductID: + freeRow = &rows[i] + case halfProductID: + halfRow = &rows[i] + } + } + if freeRow == nil || halfRow == nil { + t.Fatalf("les deux produits attendus doivent être présents: %+v", rows) + } + if freeRow.Price != 0 { + t.Errorf("produit de la config free_product: le prix en panier doit être 0: got=%.2f", freeRow.Price) + } + if halfRow.Price != 5.0 { + t.Errorf("produit de la config half_price_product: prix attendu = 50%% de 10.00€ = 5.00€: got=%.2f", halfRow.Price) + } +} + +// Quand une catégorie n'est pas configurée en "tous les produits", chaque +// produit sélectionné a sa propre quantité (ex: produit A à 2g offerts, +// produit B à 1g offert, tous deux dans la même catégorie et le même type). +func TestClaimMyReward_HTTPFlow_PerProductQuantityWithinSameCategoryAndType(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_http_per_product_qty") + productA := newTestProduct(t, "RewardHTTPPerProductA", 5) + productB := newTestProduct(t, "RewardHTTPPerProductB", 5) + // newTestProduct crée les deux produits dans la catégorie "test". + + configureRewardSettings(t, &models.PointsReward{ + Threshold: 20, + Description: "Produit A 2g offert, produit B 1g offert", + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{ + {ProductID: productA, Quantity: 2}, + {ProductID: productB, Quantity: 1}, + }}, + }, + }) + setClientPoolPoints(t, username, "pool_0", 20) + + body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"}) + c, rec := claimRewardContext(username, body) + handlers.ClaimMyReward(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + rows := basketRewardItems(t, username) + if len(rows) != 2 { + t.Fatalf("les deux produits récompense doivent être dans le panier: %+v", rows) + } + + var rowA, rowB *rewardBasketRow + for i := range rows { + switch rows[i].ProductID { + case productA: + rowA = &rows[i] + case productB: + rowB = &rows[i] + } + } + if rowA == nil || rowB == nil { + t.Fatalf("les deux produits attendus doivent être présents: %+v", rows) + } + if rowA.Quantity != 2 { + t.Errorf("produit A: quantité attendue=2, got=%.2f", rowA.Quantity) + } + if rowB.Quantity != 1 { + t.Errorf("produit B: quantité attendue=1, got=%.2f", rowB.Quantity) + } +} diff --git a/backend/gestion/tests/rewards_test.go b/backend/gestion/tests/rewards_test.go index f2425f35..121baadf 100644 --- a/backend/gestion/tests/rewards_test.go +++ b/backend/gestion/tests/rewards_test.go @@ -1,6 +1,7 @@ package tests import ( + "gestion/db" "gestion/models" "strings" "sync" @@ -40,6 +41,41 @@ func basketRewardItems(t *testing.T, username string) []rewardBasketRow { return rows } +// Désactiver la récompense (PointsReward = nil) puis sauvegarder ne doit pas +// la faire réapparaître activée au rechargement — régression : json.Marshal +// d'un pointeur nil produit la chaîne "null", et json.Unmarshal d'un null +// JSON dans une valeur non-pointeur est un no-op sans erreur, ce qui laissait +// settings.PointsReward pointer vers une struct vide mais non-nil. +func TestUpdateSettings_DisablingPointsRewardPersistsAsNil(t *testing.T) { + settings := db.DefaultSettings() + settings.PointsReward = &models.PointsReward{ + Threshold: 20, + Description: "Un produit offert", + } + if err := testDB.UpdateSettings(settings); err != nil { + t.Fatalf("UpdateSettings (activation): %v", err) + } + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings (activation): %v", err) + } + if loaded.PointsReward == nil { + t.Fatal("la récompense devrait être active après la première sauvegarde") + } + + settings.PointsReward = nil + if err := testDB.UpdateSettings(settings); 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.PointsReward != nil { + t.Errorf("la récompense désactivée ne doit pas réapparaître après sauvegarde: got=%+v", loaded.PointsReward) + } +} + // ── ClaimPoolReward : seuil, atomicité, épuisement ────────────────────────── func TestClaimPoolReward_BelowThresholdFails(t *testing.T) { diff --git a/backend/gestion/tests/settings_persistence_test.go b/backend/gestion/tests/settings_persistence_test.go new file mode 100644 index 00000000..2c9c9ef9 --- /dev/null +++ b/backend/gestion/tests/settings_persistence_test.go @@ -0,0 +1,390 @@ +package tests + +import ( + "bytes" + "encoding/json" + "gestion/db" + "gestion/handlers" + "gestion/models" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +// resetSettingsAfterTest restaure les settings par défaut à la fin du test — +// AppSettings est un état global partagé (une seule ligne par clé dans +// app_settings), donc un test qui le modifie ne doit pas laisser de résidu +// pour les tests suivants (ex: DeliveryMode utilisé par d'autres suites). +func resetSettingsAfterTest(t *testing.T) { + t.Helper() + t.Cleanup(func() { + if err := testDB.UpdateSettings(db.DefaultSettings()); err != nil { + t.Logf("⚠️ resetSettingsAfterTest: restauration des settings par défaut échouée: %v", err) + } + }) +} + +// ── Bascules booléennes (activer/désactiver une option) ───────────────────── +// +// Régression visée : chaque option doit persister à sa valeur exacte après un +// cycle save→reload, dans les deux sens (activation ET désactivation) — voir +// TestUpdateSettings_DisablingPointsRewardPersistsAsNil pour un cas où la +// désactivation ne persistait pas correctement. +func TestUpdateSettings_DisablingBooleanTogglesPersists(t *testing.T) { + resetSettingsAfterTest(t) + + set := func(v bool) models.AppSettings { + s := db.DefaultSettings() + s.PenaltiesEnabled = v + s.ShowAmendeScore = v + s.PointsEnabled = v + s.ReferralEnabled = v + s.CryptoPaymentEnabled = v + s.CryptoOnly = v + s.TelegramNotificationsEnabled = v + s.Telegram2FAEnabled = v + return s + } + + assertAll := func(t *testing.T, want bool) { + t.Helper() + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + checks := map[string]bool{ + "penalties_enabled": loaded.PenaltiesEnabled, + "show_amende_score": loaded.ShowAmendeScore, + "points_enabled": loaded.PointsEnabled, + "referral_enabled": loaded.ReferralEnabled, + "crypto_payment_enabled": loaded.CryptoPaymentEnabled, + "crypto_only": loaded.CryptoOnly, + "telegram_notifications_enabled": loaded.TelegramNotificationsEnabled, + "telegram_2fa_enabled": loaded.Telegram2FAEnabled, + } + for key, got := range checks { + if got != want { + t.Errorf("%s: got=%v want=%v", key, got, want) + } + } + } + + if err := testDB.UpdateSettings(set(true)); err != nil { + t.Fatalf("UpdateSettings (activation): %v", err) + } + assertAll(t, true) + + if err := testDB.UpdateSettings(set(false)); err != nil { + t.Fatalf("UpdateSettings (désactivation): %v", err) + } + assertAll(t, false) +} + +// ── Options non-booléennes (hors NowPayments) ─────────────────────────────── + +// Le barème des amendes (penalty_tiers) est éditable dans l'admin +// ("Barème des amendes") mais aucune clé "penalty_tiers" n'existe dans les +// pairs persistées par UpdateSettings ni dans le switch de GetSettings — la +// configuration saisie par l'admin est donc silencieusement perdue au +// prochain rechargement, et retombe toujours sur le barème par défaut. +func TestUpdateSettings_PenaltyTiersRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.PenaltyTiers = []models.PenaltyTier{ + {MinCancel: 0, Amount: 10}, + {MinCancel: 5, Amount: 999}, + } + 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 len(loaded.PenaltyTiers) != 2 || loaded.PenaltyTiers[1].Amount != 999 { + t.Errorf("le barème des amendes personnalisé n'a pas été persisté: got=%+v", loaded.PenaltyTiers) + } +} + +func TestUpdateSettings_ReferralAmountRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.ReferralAmount = 12.5 + 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.ReferralAmount != 12.5 { + t.Errorf("referral_amount: got=%.2f want=12.50", loaded.ReferralAmount) + } + + s.ReferralAmount = 0 + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings (remise à zéro): %v", err) + } + loaded, err = testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings (remise à zéro): %v", err) + } + if loaded.ReferralAmount != 0 { + t.Errorf("referral_amount remis à 0: got=%.2f want=0.00", loaded.ReferralAmount) + } +} + +func TestUpdateSettings_PointsPoolsRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.PointsPools = []models.PointsPool{ + { + Key: "pool_custom", + Name: "Pool Custom", + Categories: []string{"catA", "catB"}, + Tiers: []models.PointsTier{{Min: 10, Max: 20, Points: 7}}, + }, + } + 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 len(loaded.PointsPools) != 1 || loaded.PointsPools[0].Key != "pool_custom" || + len(loaded.PointsPools[0].Categories) != 2 || loaded.PointsPools[0].Tiers[0].Points != 7 { + t.Errorf("points_pools personnalisé mal persisté: got=%+v", loaded.PointsPools) + } +} + +func TestUpdateSettings_DeliveryScheduleRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.DeliverySchedule.Monday = models.DaySchedule{Enabled: false, OpenTime: "10:00", CloseTime: "18:00"} + 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.DeliverySchedule.Monday.Enabled != false || + loaded.DeliverySchedule.Monday.OpenTime != "10:00" || + loaded.DeliverySchedule.Monday.CloseTime != "18:00" { + t.Errorf("delivery_schedule.monday mal persisté: got=%+v", loaded.DeliverySchedule.Monday) + } +} + +func TestUpdateSettings_PostalZonesRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.PostalZones = []models.PostalZone{ + {Name: "Zone Test", MinAmount: 42, Codes: []string{"11111", "22222"}}, + } + 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 len(loaded.PostalZones) != 1 || loaded.PostalZones[0].MinAmount != 42 || + len(loaded.PostalZones[0].Codes) != 2 { + t.Errorf("postal_zones mal persisté: got=%+v", loaded.PostalZones) + } +} + +func TestUpdateSettings_DeliveryModeRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.DeliveryMode = models.DeliveryModeConfig{ + Mode: "category_based", + CategoryRoutes: []models.CategoryRoute{ + {DeliverymanUsername: "livreur_test", Categories: []string{"catA"}}, + }, + } + 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.DeliveryMode.Mode != "category_based" || len(loaded.DeliveryMode.CategoryRoutes) != 1 || + loaded.DeliveryMode.CategoryRoutes[0].DeliverymanUsername != "livreur_test" { + t.Errorf("delivery_mode mal persisté: got=%+v", loaded.DeliveryMode) + } + + // Repasser en mode "single" avec une liste vide doit aussi persister + // correctement (pas de résidu de l'ancienne liste category_routes). + s.DeliveryMode = models.DeliveryModeConfig{Mode: "single", CategoryRoutes: []models.CategoryRoute{}} + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings (retour single): %v", err) + } + loaded, err = testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings (retour single): %v", err) + } + if loaded.DeliveryMode.Mode != "single" || len(loaded.DeliveryMode.CategoryRoutes) != 0 { + t.Errorf("delivery_mode retour à single mal persisté: got=%+v", loaded.DeliveryMode) + } +} + +func TestUpdateSettings_ShopAndTelegramTextFieldsRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.ShopName = "Ma Boutique Test" + s.TelegramBotToken = "123456:ABC-test-token" + s.TelegramBotUsername = "mon_bot_test" + 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.ShopName != "Ma Boutique Test" { + t.Errorf("shop_name: got=%q want=%q", loaded.ShopName, "Ma Boutique Test") + } + if loaded.TelegramBotToken != "123456:ABC-test-token" { + t.Errorf("telegram_bot_token: got=%q", loaded.TelegramBotToken) + } + if loaded.TelegramBotUsername != "mon_bot_test" { + t.Errorf("telegram_bot_username: got=%q", loaded.TelegramBotUsername) + } + + // Effacer le token/username (chaîne vide) doit aussi persister tel quel — + // contrairement à contact_telegram qui a un repli explicite non-vide. + s.TelegramBotToken = "" + s.TelegramBotUsername = "" + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings (effacement): %v", err) + } + loaded, err = testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings (effacement): %v", err) + } + if loaded.TelegramBotToken != "" || loaded.TelegramBotUsername != "" { + t.Errorf("token/username effacés devraient rester vides: got token=%q username=%q", loaded.TelegramBotToken, loaded.TelegramBotUsername) + } +} + +func TestUpdateSettings_ColorAndGradientFieldsRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.AdminColorPrimary = "#111111" + s.ClientColorDanger = "#222222" + s.ClientTitleGradientFrom = "#333333" + s.ClientTitleGradientTo = "#444444" + 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.AdminColorPrimary != "#111111" { + t.Errorf("admin_color_primary: got=%q", loaded.AdminColorPrimary) + } + if loaded.ClientColorDanger != "#222222" { + t.Errorf("client_color_danger: got=%q", loaded.ClientColorDanger) + } + if loaded.ClientTitleGradientFrom != "#333333" || loaded.ClientTitleGradientTo != "#444444" { + 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]) + } +} diff --git a/frontend-admin/App.tsx b/frontend-admin/App.tsx index d9120788..9465bb05 100644 --- a/frontend-admin/App.tsx +++ b/frontend-admin/App.tsx @@ -101,8 +101,8 @@ export default function App() { await Updates.fetchUpdateAsync(); await Updates.reloadAsync(); } - } catch { - // Silently ignore update errors + } catch (e) { + console.error("[OTA] Échec de la vérification/application de la mise à jour:", e); } }; checkForUpdate(); diff --git a/frontend-admin/app.json b/frontend-admin/app.json index 48252ce8..969ca7d5 100644 --- a/frontend-admin/app.json +++ b/frontend-admin/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Admin Panel", "slug": "frontend-admin", - "version": "1.0.1", + "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "dark", @@ -53,7 +53,7 @@ } }, "owner": "xor290", - "runtimeVersion": "admin-1.0.1", + "runtimeVersion": "admin-1.0.0", "updates": { "url": "https://u.expo.dev/fcb7a0bc-5b2f-453d-ba16-9f97d9f0e440", "codeSigningCertificate": "./certs/certificate.pem", diff --git a/frontend-admin/certs/certificate-preprod.pem b/frontend-admin/certs/certificate-preprod.pem new file mode 100644 index 00000000..e273b312 --- /dev/null +++ b/frontend-admin/certs/certificate-preprod.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDGzCCAgOgAwIBAgIUE8d7MB8k8EDm+Ai4QgEHdIJi3nUwDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXVWJlciBTdHVwIEFkbWluIFByZXByb2QwHhcNMjYwODI2 +MTAyMjQ1WhcNMzYwODIzMTAyMjQ1WjAiMSAwHgYDVQQDDBdVYmVyIFN0dXAgQWRt +aW4gUHJlcHJvZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM55/nO+ +DDMIqAcoaRMe/IFu6GTP+iX+M4UpvJXMYH9n4oCp7cHegxrq5KkW5Q8Hg1Qic/2N +4W47G9LoEyjg58lOZISeBu6tltnfiFIMaqyuxDJvq851jFf2g4uXR2DpG4nW46dz +d36MWAbI2UyEhUKPVEJGhZc5s9eP+CECYmSDj0oyObseMcieolqCV7itSzwmck2e +VWrbOJF5TgQ26G8buA8gXUbJUVHMyan8LDWDl/+JTckJ1ENdcrBPyjA/ce3wbVBV +m33Te758JWb5wxAP2nMi2rqy/GdvgQDH6m82u/BBEFsmk+Nn7PJBYlEUeUP0rdf5 +RqPJH0bzuPUzygkCAwEAAaNJMEcwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM +MAoGCCsGAQUFBwMDMB0GA1UdDgQWBBSObwIT1/4owQnnb9fpdKQJyIeZXjANBgkq +hkiG9w0BAQsFAAOCAQEAKFPhk6qGylJzpjJzh7WTJrD78zkvzQfyl2OLHLy6q4HI +0NUeKlwGccUe6ujvB85HBqlLox3mQOB4uuR3hz1fKhIJ2StNvX/3Ko/da8a+WeiN +ZfniBDNPUKAaRG6/DH80n83r7GT07hHq4zJrWIauOOSdkOmwHYrHl79ceNk94WhC +XHtr+9/n/z0WG83NePHPPqnTT/IRCpWPCNzFQf1vT7GPWTKaRjTKfRpgAFzzumho +wj65OMSD5ZRenkTG7KMxssYRN+2UPeoZ+nAKgx0K5vVbFfFVfogfYLFf6xwvXg8i +1Iokq3r8g7BACGJlWxPqtDo272lOD7HdQ7sLxLqxUw== +-----END CERTIFICATE----- diff --git a/frontend-admin/certs/certificate.pem b/frontend-admin/certs/certificate.pem index a53e9f6b..ad3b6516 100644 --- a/frontend-admin/certs/certificate.pem +++ b/frontend-admin/certs/certificate.pem @@ -1,18 +1,19 @@ ------BEGIN CERTIFICATE----- -MIIC9zCCAd+gAwIBAgIJOs/S1ceI7Zq6MA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV -BAMTGk1pbGlldSBOYW50YWlzIC8gVWJlciBTdHVwMB4XDTI2MDcxMTIwNDI1MloX -DTM2MDcxMTIwNDI1MlowJTEjMCEGA1UEAxMaTWlsaWV1IE5hbnRhaXMgLyBVYmVy -IFN0dXAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQClkcj0h9gBuq9z -FVT1UwhBto2sTZglO3iwsgyPWx7I7twyY+kUU57rgHzI7YGieX599xM4oGjau8r7 -PjPK1160djTuRa9bWXnYBnotaU0Hp3rOicxbMygCGQoZtDqxRUMo4HxrBSYnZaVo -VYPqs/utSTA43El7SrzJddxBZK4WbJbfgdXDYrdeLz4Syrdx8DXBnCYmmmHhpQsE -orykYCUi7qd0CJi6kZGVOgR+Hq0B581DqnUA2H3iQWdk/0EZf6PN/gR0f9YlH3oN -N8tYLo7TOScSmUNJ5T2hFEuWuS/O6JKUI6a7MpIOv7XYxNDYWT/Ae9QNT8GcqVcW -39j2xSvvAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggr -BgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAj+SMnO7/IFajlg6Uo6aJotM8vdPp -4Dgi18DURZ0evUsxm4lLHWQ//6zF8jVaqPwsKA9IuPW+8O8gC8iwCY3YfjbaC5Ad -vPlvpzU6bvaA//utLVVUlfkk87vs5QotJkshoImJJoDPfO/Q1yv1qrMHXPnGyyzV -K0K3rYeXVYMDeJ9y2742D+MEg0Zse7xmNcde2z5aUuFlK7ORBs03FohD2U5zUqUg -jaia/wN4lIMCdJJmoPRUydbLJ8yVns9whFxXU1eGqaFf27jBdI/nPMVmO1YPsxnk -VvOiP6n1T+aZ7qaeOY9hsSmJ9FBeh3pOtRrdxmA8wBxD3zAORfLl89mjsw== ------END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDCzCCAfOgAwIBAgIUP26Wjyp3YylJDp5TspqcnBfttXgwDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMB4XDTI2MDgyNjEwMjI0NVoX +DTM2MDgyMzEwMjI0NVowGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv5BLKcCBTmbDf5kh8Iwrtuhbhizt +kHF1CsR8CRh4diuoXT8fdlwmQ6xf8tYFMkF4Q1ytHXHZ1VfLslU4fWVTErJW9/e0 +yTx1sP4sITzpujkOTSeFlvNxJ2Y6MKFoqwxVG/999oSteNTLAQeBNbnwgHox7Bu1 +WJGV3fAjv7y6VttH/u9ZUtAn6dwrHcsGFZ5vqr4z2ZMM+dU1L/sjF41wQAaCLSpY +5rjch2FeD1gjVFpVMmMqxJado7B4UPcYZf1YCftjpp3Ojb0ZCy11uXo7rIOYR5EB +g1vTEgkMIp7CsC4FUdZKNltHDkNiml7hELp29C+auDjcHRkYZvSZNPa6vQIDAQAB +o0kwRzAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwMwHQYD +VR0OBBYEFI1iBsChUiK+E/rHk0O4Sr7gPgRpMA0GCSqGSIb3DQEBCwUAA4IBAQBf +q3IFEOFM+7FNuYEUDhNDjAC6teZPbM5yUMeX13Ei3MOdalaNCwuTTSQTIrBpjMpm +Lqd6y/qjF/jefXDOF4VHUv/MWhTtwlklPB4zvYK81gZu0piNK9CDPgnoYa8WASlj +8MZURgmmVHvoCAVjtqVrU+8H4SFTCL0SxBq1giJwqyEogsMGyaTIXDfOn0+HRsEg +BZatKJwWCSHCox18i+6gMED+WsgrS/topvjiV7PR6iZQGckT1rEmG11m2IjgrvFt +MFXPeyDEhvr2E9cqaOyMgRP/r+0f5AhELzZygom+9XTXdNwvGQuUuT76YiQGfJEf +B64IP3rw+0Rs+9XAHXF3 +-----END CERTIFICATE----- diff --git a/frontend-admin/eas.json b/frontend-admin/eas.json index aab6d181..dac6f412 100644 --- a/frontend-admin/eas.json +++ b/frontend-admin/eas.json @@ -23,7 +23,7 @@ }, "env": { "EXPO_PUBLIC_API_URL": "https://uber-demo.club", - "EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest" + "EXPO_PUBLIC_UPDATE_URL": "https://ota-preprod.uber-stup.club/api/manifest" }, "channel": "pre-prod-admin" }, @@ -35,7 +35,7 @@ }, "env": { "EXPO_PUBLIC_API_URL": "https://mln-uber.club", - "EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest" + "EXPO_PUBLIC_UPDATE_URL": "https://ota-prod.uber-stup.club/api/manifest" }, "channel": "production-admin" } diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index c442501c..7cfb5650 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -1107,24 +1107,36 @@ export interface PointsTier { points: number; } +export interface RewardProductQuantity { + product_id: number; + quantity: number; // quantité individuelle de ce produit (palier de prix catalogue, ex: 1g) +} + export interface RewardCategoryConfig { category: string; type: "free_product" | "half_price_product"; all_products: boolean; - product_ids: number[]; -} - -export interface RewardItem { - product_id: number; - quantity: number; - price: number; + quantity: number; // quantité uniforme si all_products = true + products: RewardProductQuantity[]; // produits + quantité individuelle si all_products = false } export interface PointsReward { threshold: number; description: string; category_configs: RewardCategoryConfig[]; - reward_items: RewardItem[]; +} + +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 { @@ -1226,6 +1238,8 @@ export interface AppSettings { points_enabled: boolean; points_pools: PointsPool[]; points_reward?: PointsReward | null; + promotions_enabled: boolean; + promotions: CategoryPromotionConfig[]; referral_enabled: boolean; delivery_schedule: DeliverySchedule; postal_zones: PostalZone[]; diff --git a/frontend-admin/src/api/api_cabine.ts b/frontend-admin/src/api/api_cabine.ts index 1be5da95..6905af65 100644 --- a/frontend-admin/src/api/api_cabine.ts +++ b/frontend-admin/src/api/api_cabine.ts @@ -164,7 +164,6 @@ export const getDeliverymanLocationForCommand = async (commandId: number) => { // ============================================ // LIVREURS -// ============================================ const parseStatus = (status: any): "available" | "busy" | "offline" => { if (!status) return "offline"; diff --git a/frontend-admin/src/api/tomtom.ts b/frontend-admin/src/api/tomtom.ts index 2a3bb8fc..56b96e4a 100644 --- a/frontend-admin/src/api/tomtom.ts +++ b/frontend-admin/src/api/tomtom.ts @@ -123,7 +123,6 @@ export async function calculateRoute( } } - // Fallback: straight line if (coordinates.length === 0) { coordinates.push(origin, destination); } diff --git a/frontend-admin/src/auth/tokenStorage.ts b/frontend-admin/src/auth/tokenStorage.ts index a8e35f81..1a33531c 100644 --- a/frontend-admin/src/auth/tokenStorage.ts +++ b/frontend-admin/src/auth/tokenStorage.ts @@ -31,7 +31,6 @@ export const getRole = () => AsyncStorage.getItem(ROLE_KEY); export const setRole = (role: string) => AsyncStorage.setItem(ROLE_KEY, role); export const removeRole = () => AsyncStorage.removeItem(ROLE_KEY); -// Clear all auth data export const clearAllAuth = async () => { await AsyncStorage.multiRemove([ TOKEN_KEY, diff --git a/frontend-admin/src/screens/admin/DeliveryScreen.tsx b/frontend-admin/src/screens/admin/DeliveryScreen.tsx index 13ccfc6d..b52620aa 100644 --- a/frontend-admin/src/screens/admin/DeliveryScreen.tsx +++ b/frontend-admin/src/screens/admin/DeliveryScreen.tsx @@ -16,7 +16,6 @@ import { StatusBar, useWindowDimensions, ScrollView, - Pressable, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { spacing, fontSize, borderRadius } from "../../theme"; @@ -503,6 +502,9 @@ export default function DeliveryScreen() { padding: spacing.l, maxHeight: "80%", }, + ratingsList: { + padding: spacing.s, + }, ratingsHeader: { flexDirection: "row", justifyContent: "space-between", @@ -1076,69 +1078,67 @@ export default function DeliveryScreen() { animationType="slide" onRequestClose={() => setRatingsModal(null)} > - setRatingsModal(null)}> - {}}> - - - - Avis — {ratingsModal?.username} - - setRatingsModal(null)}> - - - - {ratingsLoading ? ( - Chargement... - ) : ratingsModal && ratingsModal.count > 0 ? ( - <> - - {[1,2,3,4,5].map((s) => ( - - ))} - - {ratingsModal.average.toFixed(1)} - - - ({ratingsModal.count} avis) - - - - {ratingsModal.ratings.map((r) => ( - - - {r.client_username} - - {new Date(r.created_at).toLocaleDateString("fr-FR")} - - - - {[1,2,3,4,5].map((s) => ( - - ))} - - {r.comment !== "" && ( - "{r.comment}" - )} - - ))} - - - ) : ( - Aucun avis pour ce livreur - )} + + + + + Avis — {ratingsModal?.username} + + setRatingsModal(null)}> + + - - + {ratingsLoading ? ( + Chargement... + ) : ratingsModal && ratingsModal.count > 0 ? ( + <> + + {[1,2,3,4,5].map((s) => ( + + ))} + + {ratingsModal.average.toFixed(1)} + + + ({ratingsModal.count} avis) + + + + {ratingsModal.ratings.map((r) => ( + + + {r.client_username} + + {new Date(r.created_at).toLocaleDateString("fr-FR")} + + + + {[1,2,3,4,5].map((s) => ( + + ))} + + {r.comment !== "" && ( + "{r.comment}" + )} + + ))} + + + ) : ( + Aucun avis pour ce livreur + )} + + {/* ── Modal historique de connexion livreur ── */} @@ -1148,12 +1148,8 @@ export default function DeliveryScreen() { animationType="slide" onRequestClose={() => setLoginHistoryModal(null)} > - setLoginHistoryModal(null)} - > - {}}> - + + Connexions — {loginHistoryModal?.username} @@ -1258,7 +1254,7 @@ export default function DeliveryScreen() { ) : loginHistoryModal && loginHistoryModal.weeks.length > 0 ? ( - + {loginHistoryModal.weeks.map((week) => ( @@ -1310,9 +1306,8 @@ export default function DeliveryScreen() { Aucune connexion ce mois-ci )} - - - + + ); diff --git a/frontend-admin/src/screens/admin/SettingsScreen.tsx b/frontend-admin/src/screens/admin/SettingsScreen.tsx index e58d03a3..0fb7af5d 100644 --- a/frontend-admin/src/screens/admin/SettingsScreen.tsx +++ b/frontend-admin/src/screens/admin/SettingsScreen.tsx @@ -17,7 +17,7 @@ import { Ionicons } from "@expo/vector-icons"; import { spacing, fontSize, borderRadius } from "../../theme"; import { useTheme } from "../../context/ThemeContext"; 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, RewardItem, 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 AlertModal from "../../components/ui/AlertModal"; import { useAlert } from "../../hooks/useAlert"; @@ -49,7 +49,7 @@ const DAYS: { key: keyof DeliverySchedule; label: string }[] = [ // ────────────────────────────────────────────────────────────── // Composant section accordéon générique -// ────────────────────────────────────────────────────────────── + function AccordionSection({ title, badge, @@ -695,7 +695,6 @@ const EMPTY_REWARD: PointsReward = { threshold: 20, description: "", category_configs: [], - reward_items: [], }; // ────────────────────────────────────────────────────────────── @@ -717,10 +716,29 @@ function CategoryProductPicker({ const catProducts = products.filter((p) => p.category === catConfig.category); const toggleProduct = (id: number) => { - const ids = catConfig.product_ids.includes(id) - ? catConfig.product_ids.filter((x) => x !== id) - : [...catConfig.product_ids, id]; - onChange({ ...catConfig, product_ids: ids, all_products: false }); + 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, plutôt + // qu'une valeur arbitraire qui pourrait ne correspondre à aucun palier réel. + 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 ( @@ -728,7 +746,7 @@ function CategoryProductPicker({ {/* Toggle tous / sélection */} onChange({ ...catConfig, all_products: true, product_ids: [] })} + onPress={() => onChange({ ...catConfig, all_products: true, products: [] })} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, paddingHorizontal: spacing.m, paddingVertical: spacing.xs, @@ -759,34 +777,103 @@ function CategoryProductPicker({ - {/* Liste des produits si mode sélection */} + {/* Mode "Tous" : une quantité uniforme pour tous les produits de la catégorie */} + {catConfig.all_products && ( + + Quantité : + 0 ? String(catConfig.quantity) : ""} + onChangeText={(v) => { + const n = parseFloat(v); + onChange({ ...catConfig, quantity: isNaN(n) ? 0 : n }); + }} + placeholder="1" + placeholderTextColor={colors.textMuted} + /> + + doit correspondre à un palier de prix existant + + + )} + + {/* Mode "Sélection" : chaque produit choisi a sa propre quantité + (ex: produit A à 2g offerts, produit B à 1g offert) */} {!catConfig.all_products && ( - + {catProducts.length === 0 ? ( Aucun produit dans cette catégorie - ) : catProducts.map((p) => { - const sel = catConfig.product_ids.includes(p.id); - return ( - toggleProduct(p.id)} - style={{ - paddingHorizontal: spacing.s, paddingVertical: 4, - borderRadius: borderRadius.sm, borderWidth: 1.5, - borderColor: sel ? REWARD_ACCENT : colors.border, - backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent", - flexDirection: "row", alignItems: "center", gap: 4, - }} - > - {sel && } - - {p.name} - - - ); - })} + ) : ( + + {catProducts.map((p) => { + const sel = catConfig.products.some((pq) => pq.product_id === p.id); + return ( + toggleProduct(p.id)} + style={{ + paddingHorizontal: spacing.s, paddingVertical: 4, + borderRadius: borderRadius.sm, borderWidth: 1.5, + borderColor: sel ? REWARD_ACCENT : colors.border, + backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent", + flexDirection: "row", alignItems: "center", gap: 4, + }} + > + {sel && } + + {p.name} + + + ); + })} + + )} + + {catConfig.products.length > 0 && ( + + {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 ( + + + {prod?.name ?? `Produit #${pq.product_id}`} + + + {tiers.length === 0 ? ( + + Aucun palier de prix actif pour ce produit + + ) : tiers.map((tier) => { + const isSel = pq.quantity === tier.quantity; + return ( + updateProductQuantity(pq.product_id, tier.quantity)} + style={{ + paddingHorizontal: spacing.s, paddingVertical: 3, + borderRadius: borderRadius.sm, borderWidth: 1.5, + borderColor: isSel ? REWARD_ACCENT : colors.border, + backgroundColor: isSel ? REWARD_ACCENT + "22" : "transparent", + flexDirection: "row", alignItems: "center", gap: 4, + }} + > + {isSel && } + + {tier.quantity}{prod?.unit ?? ""} · {tier.price}€ + + + ); + })} + + + ); + })} + + )} )} @@ -819,29 +906,44 @@ function CentralRewardSection({ const update = (patch: Partial) => onChange({ ...r, ...patch }); - const getCatConfig = (catName: string): RewardCategoryConfig => - r.category_configs.find((c) => c.category === catName) ?? - { category: catName, type: "free_product", all_products: true, product_ids: [] }; + // Une catégorie peut avoir jusqu'à deux configs actives en parallèle + // (une "free_product" et une "half_price_product"), chacune avec sa + // propre sélection de produits — d'où la recherche par (catégorie, type). + const getCatConfig = (catName: string, type: RewardCategoryConfig["type"]): RewardCategoryConfig => + r.category_configs.find((c) => c.category === catName && c.type === type) ?? + { category: catName, type, all_products: true, products: [], quantity: 1 }; + + const isTypeEnabled = (catName: string, type: RewardCategoryConfig["type"]) => + r.category_configs.some((c) => c.category === catName && c.type === type); const isCatSelected = (catName: string) => r.category_configs.some((c) => c.category === catName); - const toggleCategory = (catName: string) => { - if (isCatSelected(catName)) { - update({ category_configs: r.category_configs.filter((c) => c.category !== catName) }); + const toggleCategoryType = (catName: string, type: RewardCategoryConfig["type"]) => { + if (isTypeEnabled(catName, type)) { + update({ category_configs: r.category_configs.filter((c) => !(c.category === catName && c.type === type)) }); } else { - update({ category_configs: [...r.category_configs, { category: catName, type: "free_product", all_products: true, product_ids: [] }] }); + update({ category_configs: [...r.category_configs, { category: catName, type, all_products: true, products: [], quantity: 1 }] }); } }; const updateCatConfig = (cfg: RewardCategoryConfig) => { update({ category_configs: r.category_configs.map((c) => - c.category === cfg.category ? cfg : c + c.category === cfg.category && c.type === cfg.type ? cfg : c ), }); }; + const [expandedCats, setExpandedCats] = useState>(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 rewardBadge = ( - {/* Produits récompense — proposés au client selon le type choisi - pour la catégorie de chaque produit (voir "Catégories éligibles" ci-dessous) */} - - Produits ajoutés au panier - - Quand le client réclame sa récompense, ces produits sont automatiquement ajoutés à son panier — gratuits ou à -50% selon le type configuré pour la catégorie du produit. Il doit commander au moins un produit normal. - - - {r.reward_items.map((item, idx) => { - const allProds = Object.values(productsByCategory).flat(); - const prod = allProds.find((p) => p.id === item.product_id); - return ( - - {/* Sélecteur produit */} - - {allProds.map((p) => { - const sel = item.product_id === p.id; - return ( - { - const updated = r.reward_items.map((it, i) => - i === idx ? { ...it, product_id: p.id } : it - ); - update({ reward_items: updated }); - }} - style={{ - flexDirection: "row", alignItems: "center", gap: 4, - paddingHorizontal: spacing.s, paddingVertical: 4, - borderRadius: borderRadius.sm, borderWidth: 1.5, - borderColor: sel ? REWARD_ACCENT : colors.border, - backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent", - }} - > - {sel && } - - {p.name} ({p.category}) - - - ); - })} - - - {/* Quantité + Prix + Supprimer */} - - - Qté : - 0 ? String(item.quantity) : ""} - onChangeText={(v) => { - const n = parseFloat(v); - const updated = r.reward_items.map((it, i) => - i === idx ? { ...it, quantity: isNaN(n) ? 0 : n } : it - ); - update({ reward_items: updated }); - }} - placeholder="1" - placeholderTextColor={colors.textMuted} - /> - - - Prix : - 0 ? String(item.price) : ""} - onChangeText={(v) => { - const n = parseFloat(v); - const updated = r.reward_items.map((it, i) => - i === idx ? { ...it, price: isNaN(n) ? 0 : n } : it - ); - update({ reward_items: updated }); - }} - placeholder="0" - placeholderTextColor={colors.textMuted} - /> - - - update({ reward_items: r.reward_items.filter((_, i) => i !== idx) })} - style={{ padding: spacing.xs }} - > - - - - - {prod && ( - - {prod.name}{item.quantity > 0 ? ` · x${item.quantity}` : ""}{item.price > 0 ? ` · ${item.price}€` : ""} - - )} - {!prod && ( - - Sélectionnez un produit ci-dessus - - )} - - ); - })} - - {Object.values(productsByCategory).flat().length === 0 ? ( - Aucun produit disponible - ) : ( - update({ reward_items: [...r.reward_items, { product_id: 0, quantity: 1, price: 0 }] })} - style={{ - flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.xs, - paddingHorizontal: spacing.m, paddingVertical: spacing.s, - borderRadius: borderRadius.sm, borderWidth: 1.5, - borderColor: REWARD_ACCENT + "66", - }} - > - - Ajouter un produit récompense - - )} - - - {/* Catégories éligibles — chaque catégorie choisit son propre type (produit offert ou -50%), qui s'applique aux produits récompense de cette catégorie configurés ci-dessus */} Catégories éligibles - Sélectionnez les catégories, choisissez le type de récompense pour chacune, puis tous les produits ou une sélection. + Sélectionnez une catégorie pour activer, indépendamment, un lot de produits offerts et/ou un lot de produits à -50%, chacun avec sa propre sélection de produits. {allCategories.length === 0 ? ( Aucune catégorie disponible @@ -1048,12 +1022,13 @@ function CentralRewardSection({ {allCategories.map((cat) => { const selected = isCatSelected(cat.name); + const expanded = expandedCats.has(cat.name); const catColor = cat.color || REWARD_ACCENT; return ( - {/* Chip catégorie */} + {/* Chip catégorie (couleur = au moins un type actif, clic = déplier/replier) */} toggleCategory(cat.name)} + onPress={() => toggleExpanded(cat.name)} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, alignSelf: "flex-start", @@ -1068,46 +1043,52 @@ function CentralRewardSection({ {cat.name} - {/* Type + sélecteur produits (visible si catégorie sélectionnée) */} - {selected && ( - - - {REWARD_TYPES.map((rt) => { - const cfg = getCatConfig(cat.name); - const sel = (cfg.type || "free_product") === rt.value; - return ( + {/* Les deux types de récompense, activables indépendamment */} + {expanded && ( + + {REWARD_TYPES.map((rt) => { + const typeEnabled = isTypeEnabled(cat.name, rt.value); + return ( + updateCatConfig({ ...cfg, type: rt.value })} + onPress={() => toggleCategoryType(cat.name, rt.value)} style={{ flexDirection: "row", alignItems: "center", gap: 4, + alignSelf: "flex-start", paddingHorizontal: spacing.s, paddingVertical: 4, borderRadius: borderRadius.sm, borderWidth: 1.5, - borderColor: sel ? REWARD_ACCENT : colors.border, - backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent", + borderColor: typeEnabled ? REWARD_ACCENT : colors.border, + backgroundColor: typeEnabled ? REWARD_ACCENT + "22" : "transparent", }} > - - + + + {rt.label} - ); - })} - - + {typeEnabled && ( + + )} + + ); + })} )} @@ -1118,7 +1099,7 @@ function CentralRewardSection({ {/* Récapitulatif */} - {(r.category_configs.length > 0 || r.reward_items.filter((it) => it.product_id > 0).length > 0) && ( + {r.category_configs.length > 0 && ( Récapitulatif @@ -1127,19 +1108,196 @@ function CentralRewardSection({ {r.description !== "" && ( "{r.description}" )} - {r.category_configs.map((cfg) => ( - - • {REWARD_TYPES.find((x) => x.value === (cfg.type || "free_product"))?.label} : {cfg.category} — {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s)`} + {r.category_configs.map((cfg, idx) => ( + + • {REWARD_TYPES.find((x) => x.value === (cfg.type || "free_product"))?.label} : {cfg.category} — {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(", ")}`} ))} - {r.reward_items.filter((it) => it.product_id > 0).map((it, idx) => { - const prod = Object.values(productsByCategory).flat().find((p) => p.id === it.product_id); + + )} + + )} + + ); +} + +// ────────────────────────────────────────────────────────────── +// 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 ( + + {/* Toggle tous / sélection */} + + 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", + }} + > + + + Tous ({catProducts.length}) + + + 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", + }} + > + + + Sélection + + + + + {/* Mode "Tous" : une quantité uniforme pour tous les produits de la catégorie */} + {catConfig.all_products && ( + + Quantité : + 0 ? String(catConfig.quantity) : ""} + onChangeText={(v) => { + const n = parseFloat(v); + onChange({ ...catConfig, quantity: isNaN(n) ? 0 : n }); + }} + placeholder="1" + placeholderTextColor={colors.textMuted} + /> + + doit correspondre à un palier de prix existant + + + )} + + {/* 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 && ( + + {catProducts.length === 0 ? ( + + Aucun produit dans cette catégorie + + ) : ( + + {catProducts.map((p) => { + const sel = catConfig.products.some((pq) => pq.product_id === p.id); return ( - - - - {prod?.name ?? `Produit #${it.product_id}`}{it.quantity > 0 ? ` · x${it.quantity}` : ""}{it.price > 0 ? ` · ${it.price}€` : ""} + 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 && } + + {p.name} + + ); + })} + + )} + + {catConfig.products.length > 0 && ( + + {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 ( + + + {prod?.name ?? `Produit #${pq.product_id}`} + + + {tiers.length === 0 ? ( + + Aucun palier de prix actif pour ce produit + + ) : tiers.map((tier) => { + const isSel = pq.quantity === tier.quantity; + return ( + 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 && } + + {tier.quantity}{prod?.unit ?? ""} · {tier.price}€ + + + ); + })} + ); })} @@ -1147,6 +1305,204 @@ function CentralRewardSection({ )} )} + + ); +} + +// ────────────────────────────────────────────────────────────── +// 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; + 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>(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 = ( + + + {enabled ? "Activées" : "Désactivées"} + + + ); + + return ( + + + + Promotions activées + + Réduction automatique appliquée au prix affiché et facturé, pour tout client — indépendant des points de fidélité. + + + + + + {enabled && ( + + + Catégories en promo + + Sélectionnez une catégorie, définissez le pourcentage de réduction, puis tous les produits ou une sélection avec leur quantité. + + {allCategories.length === 0 ? ( + Aucune catégorie disponible + ) : ( + + {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 ( + + 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", + }} + > + + + {cat.name}{selected ? ` · -${cfg.discount_percent}%` : ""} + + + + + {expanded && ( + + 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", + }} + > + + + + Promo active sur cette catégorie + + + + {selected && ( + <> + + Réduction : + 0 ? String(cfg.discount_percent) : ""} + onChangeText={(v) => { + const n = parseFloat(v); + updateCatConfig({ ...cfg, discount_percent: isNaN(n) ? 0 : n }); + }} + placeholder="10" + placeholderTextColor={colors.textMuted} + /> + % + + + + )} + + )} + + ); + })} + + )} + + + {/* Récapitulatif */} + {promotions.length > 0 && ( + + Récapitulatif + {promotions.map((cfg, idx) => ( + + • {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(", ")}`} + + ))} + + )} + + )} ); } @@ -1206,6 +1562,8 @@ export default function SettingsScreen() { shop_name: "Milieu-Nantais", contact_telegram: "", points_reward: null, + promotions_enabled: false, + promotions: [], admin_color_primary: "#7c3aed", admin_color_secondary: "#22d3ee", admin_color_success: "#4ade80", @@ -1277,8 +1635,19 @@ export default function SettingsScreen() { category_routes: s.delivery_mode?.category_routes ?? [], }, points_reward: s.points_reward - ? { ...s.points_reward, category_configs: s.points_reward.category_configs ?? [], reward_items: s.points_reward.reward_items ?? [] } + ? { + ...s.points_reward, + category_configs: (s.points_reward.category_configs ?? []).map((cfg) => ({ + ...cfg, + products: cfg.products ?? [], + })), + } : null, + promotions_enabled: s.promotions_enabled ?? false, + promotions: (s.promotions ?? []).map((cfg) => ({ + ...cfg, + products: cfg.products ?? [], + })), }); } if (categoriesRes) { @@ -1894,6 +2263,18 @@ export default function SettingsScreen() { s={s} /> + {/* Promotions — réduction automatique, indépendante des points */} + setSettings((p) => ({ ...p, promotions_enabled: v }))} + onChangePromotions={(promotions) => setSettings((p) => ({ ...p, promotions }))} + colors={colors} + s={s} + /> + {/* Horaires de livraison */} => { // 🏆 POINTS — RÉCOMPENSES // ============================================ +export type RewardConfigProduct = { + product_id: number; + product_name: string; + quantity: number; +}; + export type RewardCategoryConfig = { category: string; type: "free_product" | "half_price_product"; all_products: boolean; - product_ids: number[]; - product_names: string[]; - amount: number; + products: RewardConfigProduct[]; + quantity: number; }; export type RewardItemConfig = { diff --git a/frontend-prep/src/api/api_types.ts b/frontend-prep/src/api/api_types.ts index a671d763..65bcd0c1 100644 --- a/frontend-prep/src/api/api_types.ts +++ b/frontend-prep/src/api/api_types.ts @@ -358,6 +358,8 @@ export interface ProductPrice { quantity: number; price: number; 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 { id: number; diff --git a/frontend-prep/src/pages/User/ConsultationHistorique.tsx b/frontend-prep/src/pages/User/ConsultationHistorique.tsx index 1bcecde1..7321bb95 100644 --- a/frontend-prep/src/pages/User/ConsultationHistorique.tsx +++ b/frontend-prep/src/pages/User/ConsultationHistorique.tsx @@ -452,18 +452,18 @@ function ConsultationHistorique() { , ] : ( - cfg.product_names ?? + cfg.products ?? [] ).map( ( - name, + p, ) => ( { - name + p.product_name } ), diff --git a/frontend-prep/src/pages/User/ProductDetail.tsx b/frontend-prep/src/pages/User/ProductDetail.tsx index b695232a..b4293df8 100644 --- a/frontend-prep/src/pages/User/ProductDetail.tsx +++ b/frontend-prep/src/pages/User/ProductDetail.tsx @@ -106,10 +106,17 @@ function ProductDetail() { quantity: number; price: number; active_price?: boolean; + promo_price?: number; + promo_percent?: number; }) => ({ quantity: parseFloat(String(p.quantity)), price: parseFloat(String(p.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) if (fixedProduct.prices.length > 0) { - setSelectedGrams(fixedProduct.prices[0].quantity); - setSelectedPrice(fixedProduct.prices[0].price); + const first = fixedProduct.prices[0]; + setSelectedGrams(first.quantity); + setSelectedPrice(first.promo_price ?? first.price); } // Couleur de la catégorie depuis la DB @@ -152,7 +160,11 @@ function ProductDetail() { ); 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() {

{product.name}

- {selectedPrice > 0 && ( -

- {selectedPrice.toFixed(2)} €{" "} - {selectedGrams && - `pour ${selectedGrams}${product.unit || "g"}`} -

- )} + {selectedPrice > 0 && (() => { + const selectedTier = product.prices?.find( + (p) => p.quantity === selectedGrams, + ); + const hasPromo = + selectedTier?.promo_price != null && + selectedTier.promo_price < selectedTier.price; + return ( +

+ {hasPromo && ( + + {selectedTier!.price.toFixed(2)} € + + )} + + {selectedPrice.toFixed(2)} € + {" "} + {selectedGrams && + `pour ${selectedGrams}${product.unit || "g"}`} + {hasPromo && ` (-${selectedTier!.promo_percent}%)`} +

+ ); + })()}

Description

@@ -345,7 +373,9 @@ function ProductDetail() { > {p.quantity} {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)} €`} ))} diff --git a/mobile/.gitignore b/mobile/.gitignore index 1cc608e1..f802a9af 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -30,6 +30,7 @@ yarn-error.* .DS_Store *.pem !certs/certificate.pem +!certs/certificate-preprod.pem # local env files .env*.local diff --git a/mobile/app.json b/mobile/app.json index 0a0d1683..d924ba68 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Milieu Nantais", "slug": "frontend-client", - "version": "1.0.2", + "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "dark", @@ -52,7 +52,7 @@ "router": {} }, "owner": "xor290", - "runtimeVersion": "client-1.0.2", + "runtimeVersion": "client-1.0.0", "updates": { "url": "https://u.expo.dev/110d06c8-a8d5-4b3c-b262-0d4d7509ae9d", "codeSigningCertificate": "./certs/certificate.pem", diff --git a/mobile/certs/certificate-preprod.pem b/mobile/certs/certificate-preprod.pem new file mode 100644 index 00000000..9fd92e4a --- /dev/null +++ b/mobile/certs/certificate-preprod.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDHTCCAgWgAwIBAgIUfteDb4QnVA4dHw75YlHAKlxv7e0wDQYJKoZIhvcNAQEL +BQAwIzEhMB8GA1UEAwwYVWJlciBTdHVwIENsaWVudCBQcmVwcm9kMB4XDTI2MDgy +NjEwMjI0NVoXDTM2MDgyMzEwMjI0NVowIzEhMB8GA1UEAwwYVWJlciBTdHVwIENs +aWVudCBQcmVwcm9kMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvc/P +p8uCt0SHb+tCaM/Pi7wm8QBKf6qnSnFr+peHmM3xWZkSEk7v4NelTlwcJ/A+Azfv +0Py7euIGdOU13bXZRSDP5wbXVOJJt1eftJsiWlOT6ehGrnZOHd+telnTnl/fWbjJ +qtDphpt3bm0DfxUypatG/NAnQ1SEiLMyUwiBTrIWoLFQ+XbC6ULnoKfhROqXj1h7 +eR+xCJ28R+LuB+kJk8EhD8L4CZqlO/xVk93eN3oJuTHJYT7jWff2uT+1SczRVvv4 +ZmnznU/gUPXJcQlISnmvaG/+8Ng8Z9ThDKDnnneJpFXAhFqU62Wjb/Y9rTb9FWZn +saBQuDZGp+nBVHcT5QIDAQABo0kwRzAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/ +BAwwCgYIKwYBBQUHAwMwHQYDVR0OBBYEFE54b6JtwnO9lw5asoSxEQ77B3FHMA0G +CSqGSIb3DQEBCwUAA4IBAQA/UDUlMYaYaArtYl/BEKSj7jZTC3gFRA8393XLFdUi +/roiIdd6suX+T957wgXRSTpGPfFVO+azJChosEKMRI477r0vWRX4J8B0GXNo+jcr +okMjt5cY6G1egTvl+slJANoevJAClgOVOZ/+HShB0k9i9sIJf/rViKj7OV19UEur +0m2gK/qdvxbeFuw2RUq5tFRgUZzL8TyZmbJVKu0iRX4wB1MuUezDlr5a/k1qd27V +24U0+IiAQTjTVZj1ab8k6oP6376p6ydKoL2JLR7A/f/B1y/TojJbDNsU5EMCMt64 +TRZ0aeslLtcnynqlpzr3JNXugtRPaz2WxT8NmB8H6fQR +-----END CERTIFICATE----- diff --git a/mobile/certs/certificate.pem b/mobile/certs/certificate.pem index a53e9f6b..5609446e 100644 --- a/mobile/certs/certificate.pem +++ b/mobile/certs/certificate.pem @@ -1,18 +1,19 @@ ------BEGIN CERTIFICATE----- -MIIC9zCCAd+gAwIBAgIJOs/S1ceI7Zq6MA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV -BAMTGk1pbGlldSBOYW50YWlzIC8gVWJlciBTdHVwMB4XDTI2MDcxMTIwNDI1MloX -DTM2MDcxMTIwNDI1MlowJTEjMCEGA1UEAxMaTWlsaWV1IE5hbnRhaXMgLyBVYmVy -IFN0dXAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQClkcj0h9gBuq9z -FVT1UwhBto2sTZglO3iwsgyPWx7I7twyY+kUU57rgHzI7YGieX599xM4oGjau8r7 -PjPK1160djTuRa9bWXnYBnotaU0Hp3rOicxbMygCGQoZtDqxRUMo4HxrBSYnZaVo -VYPqs/utSTA43El7SrzJddxBZK4WbJbfgdXDYrdeLz4Syrdx8DXBnCYmmmHhpQsE -orykYCUi7qd0CJi6kZGVOgR+Hq0B581DqnUA2H3iQWdk/0EZf6PN/gR0f9YlH3oN -N8tYLo7TOScSmUNJ5T2hFEuWuS/O6JKUI6a7MpIOv7XYxNDYWT/Ae9QNT8GcqVcW -39j2xSvvAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggr -BgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAj+SMnO7/IFajlg6Uo6aJotM8vdPp -4Dgi18DURZ0evUsxm4lLHWQ//6zF8jVaqPwsKA9IuPW+8O8gC8iwCY3YfjbaC5Ad -vPlvpzU6bvaA//utLVVUlfkk87vs5QotJkshoImJJoDPfO/Q1yv1qrMHXPnGyyzV -K0K3rYeXVYMDeJ9y2742D+MEg0Zse7xmNcde2z5aUuFlK7ORBs03FohD2U5zUqUg -jaia/wN4lIMCdJJmoPRUydbLJ8yVns9whFxXU1eGqaFf27jBdI/nPMVmO1YPsxnk -VvOiP6n1T+aZ7qaeOY9hsSmJ9FBeh3pOtRrdxmA8wBxD3zAORfLl89mjsw== ------END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDDTCCAfWgAwIBAgIUJrRf0VNrabHd9GaoW3iBxw8RzygwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQVWJlciBTdHVwIENsaWVudDAeFw0yNjA4MjYxMDIyNDVa +Fw0zNjA4MjMxMDIyNDVaMBsxGTAXBgNVBAMMEFViZXIgU3R1cCBDbGllbnQwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2foB+W04MkXIyANrUeffDy1Vo +soZ6UgD/FWvvzYY0Pf29kWsatBTfuwfMQPg4VVl2OXci7oL01Steml2ZOMb+kF0f +n3da5dRFFPVEp7KI+y1bRSBcy3M79Y8oon2Q1TcooCeaKVXVx7Ykg6/GPDLL/+tI +0HVXdtQxMDr1EcCZuTo2g+91o84MLXJXipHkuS64UYAjlPrJFcq9jR4TJ7zYsL01 +P9fPmokAm2Vc2B9dG+BjlSBXp7oyLIOtMwC1zACkWiU+81d59NMZAv04FJENB/P6 +xeG3WqM/n/9INGy2Xvd9wkUuZEObZJiNh4CL6ZWzhJPCPq2cHcqrcHxXeRtxAgMB +AAGjSTBHMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzAd +BgNVHQ4EFgQUcyo31IaveB8XCpMt/m0rLvC/kk4wDQYJKoZIhvcNAQELBQADggEB +ACVgBn04MtRN/VysKaus837+x5XtiXm+V6Bi57+JkqORKEgzV2PdmFCtpcG6ePef +0uUVkK7IF2tGm1AfNUkwvw/CoKNaFe9rtcNMLVYZSDbn6KOAyBSAxb2yQewJaSLN +/qpjkg45Jrcwyl0cQ6tQfQgWmliXE1AbgAN5j0foKA0b4ioLsI0dPFncYo5hzmOb +9FQ7QGbHwiAMmnQ3PHbpoby6DVpmEeuIj22FgAxt9TI7bYon/OHVO894jN0CCEOJ +8wrAUxgIGJKLFYSQdodaQ4WESyYnAcGTneqypC+l9yJQBWNUT+cJeNaQmu47Zbra +eLmJVdPXnnRe2UT7wSa02xg= +-----END CERTIFICATE----- diff --git a/mobile/eas.json b/mobile/eas.json index 3fcde482..17452134 100644 --- a/mobile/eas.json +++ b/mobile/eas.json @@ -23,7 +23,7 @@ }, "env": { "EXPO_PUBLIC_API_URL": "https://uber-demo.club", - "EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest" + "EXPO_PUBLIC_UPDATE_URL": "https://ota-mobile-preprod.uber-stup.club/api/manifest" }, "channel": "pre-prod-client" }, @@ -35,7 +35,7 @@ }, "env": { "EXPO_PUBLIC_API_URL": "https://mln-uber.club", - "EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest" + "EXPO_PUBLIC_UPDATE_URL": "https://ota-mobile-prod.uber-stup.club/api/manifest" }, "channel": "production-client" } diff --git a/mobile/src/api/api.ts b/mobile/src/api/api.ts index 495487bf..644e33b3 100644 --- a/mobile/src/api/api.ts +++ b/mobile/src/api/api.ts @@ -529,7 +529,6 @@ export const getOrdersWithTracking = async () => { // ============================================ // HISTORY -// ============================================ export const getMyCompletedOrders = async (): Promise => { try { @@ -962,13 +961,18 @@ export const toggle2FA = async ( // 🏆 POINTS — RÉCOMPENSES // ============================================ +export type RewardConfigProduct = { + product_id: number; + product_name: string; + quantity: number; +}; + export type RewardCategoryConfig = { category: string; type: "free_product" | "half_price_product"; all_products: boolean; - product_ids: number[]; - product_names: string[]; - amount: number; + products: RewardConfigProduct[]; + quantity: number; }; export type RewardItemConfig = { diff --git a/mobile/src/api/api_types.ts b/mobile/src/api/api_types.ts index b66244cb..5876835b 100644 --- a/mobile/src/api/api_types.ts +++ b/mobile/src/api/api_types.ts @@ -355,6 +355,8 @@ export interface ProductPrice { quantity: number; price: number; 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 { id: number; diff --git a/mobile/src/auth/tokenStorage.ts b/mobile/src/auth/tokenStorage.ts index 57d70ad9..b61a7ff3 100644 --- a/mobile/src/auth/tokenStorage.ts +++ b/mobile/src/auth/tokenStorage.ts @@ -32,7 +32,6 @@ export const getRole = () => AsyncStorage.getItem(ROLE_KEY); export const setRole = (role: string) => AsyncStorage.setItem(ROLE_KEY, role); export const removeRole = () => AsyncStorage.removeItem(ROLE_KEY); -// Clear all auth data export const clearAllAuth = async () => { await AsyncStorage.multiRemove([ TOKEN_KEY, diff --git a/mobile/src/screens/client/OrderHistoryScreen.tsx b/mobile/src/screens/client/OrderHistoryScreen.tsx index f1dc37a4..ee4a6bc0 100644 --- a/mobile/src/screens/client/OrderHistoryScreen.tsx +++ b/mobile/src/screens/client/OrderHistoryScreen.tsx @@ -830,14 +830,14 @@ export default function OrderHistoryScreen() { , ] : ( - cfg.product_names ?? + cfg.products ?? [] ).map( ( - name, + p, ) => ( { - name + p.product_name } diff --git a/mobile/src/screens/client/ProductDetailScreen.tsx b/mobile/src/screens/client/ProductDetailScreen.tsx index d65ff269..1306b46a 100644 --- a/mobile/src/screens/client/ProductDetailScreen.tsx +++ b/mobile/src/screens/client/ProductDetailScreen.tsx @@ -63,12 +63,18 @@ export default function ProductDetailScreen() { quantity: parseFloat(String(pr.quantity)), price: parseFloat(String(pr.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); if (fixedProduct.prices.length > 0) { - setSelectedGrams(fixedProduct.prices[0].quantity); - setSelectedPrice(fixedProduct.prices[0].price); + const first = fixedProduct.prices[0]; + setSelectedGrams(first.quantity); + setSelectedPrice(first.promo_price ?? first.price); } const matched = categories.find( (c) => @@ -90,7 +96,7 @@ export default function ProductDetailScreen() { const handleGramsChange = (quantity: number) => { setSelectedGrams(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); }; @@ -585,16 +591,45 @@ export default function ProductDetailScreen() { {product.name} - {selectedPrice > 0 && ( - - - - {selectedPrice.toFixed(2)} €{" "} - {selectedGrams && - `pour ${selectedGrams}${product.unit || "g"}`} - - - )} + {selectedPrice > 0 && (() => { + const selectedTier = product.prices?.find( + (p) => p.quantity === selectedGrams, + ); + const hasPromo = + selectedTier?.promo_price != null && + selectedTier.promo_price < selectedTier.price; + return ( + + + {hasPromo && ( + + {selectedTier!.price.toFixed(2)} € + + )} + + {selectedPrice.toFixed(2)} €{" "} + {selectedGrams && + `pour ${selectedGrams}${product.unit || "g"}`} + {hasPromo && + ` (-${selectedTier!.promo_percent}%)`} + + + ); + })()} Description @@ -719,16 +754,32 @@ export default function ProductDetailScreen() { {p.quantity} {product.unit || "g"} - - {p.price.toFixed(2)} € - + {p.promo_price != null && p.promo_price < p.price ? ( + + + {p.price.toFixed(2)} € + + + {p.promo_price.toFixed(2)} € (-{p.promo_percent}%) + + + ) : ( + + {p.price.toFixed(2)} € + + )} {selectedGrams === p.quantity && ( ;