chore: build
Backend - Build & Lint / build (push) Canceled after 11m16s
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Frontend Web - Build & Lint / build (push) Canceled after 0s

This commit is contained in:
Xor290
2026-09-08 17:44:28 +02:00
parent 06abfee274
commit 82f9a2fae9
37 changed files with 2164 additions and 598 deletions
+12 -3
View File
@@ -130,11 +130,14 @@ func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
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(&currentStock).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"`
+48
View File
@@ -0,0 +1,48 @@
package db
import (
"gestion/models"
"math"
)
// ResolvePromotionDiscount retourne le pourcentage de réduction actif pour un
// produit, sa catégorie catalogue et une quantité donnés, si une promotion
// configurée dans les settings couvre exactement ce couple (produit,
// quantité) — contrairement aux récompenses, aucun seuil de points n'entre
// en jeu : la promotion s'applique à toute commande de cette quantité.
func ResolvePromotionDiscount(settings *models.AppSettings, productID int, category string, quantity float64) (float64, bool) {
if settings == nil || !settings.PromotionsEnabled {
return 0, false
}
for _, promo := range settings.Promotions {
if promo.Category != category || promo.DiscountPercent <= 0 {
continue
}
if promo.AllProducts {
if promo.Quantity == quantity {
return promo.DiscountPercent, true
}
continue
}
for _, pq := range promo.Products {
if pq.ProductID == productID && pq.Quantity == quantity {
return promo.DiscountPercent, true
}
}
}
return 0, false
}
// ApplyPromotionToPrice applique la réduction (si une promotion couvre ce
// produit/quantité/catégorie) au prix catalogue donné, arrondi au centime.
func (d *Database) ApplyPromotionToPrice(productID int, category string, quantity, price float64) (float64, bool) {
settings, err := d.GetSettings()
if err != nil {
return price, false
}
discount, ok := ResolvePromotionDiscount(&settings, productID, category, quantity)
if !ok {
return price, false
}
return math.Round(price*(1-discount/100)*100) / 100, true
}
+65 -14
View File
@@ -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)},
+125 -131
View File
@@ -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
+33
View File
@@ -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]
}
+7
View File
@@ -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" }
+83 -35
View File
@@ -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"`
+271
View File
@@ -0,0 +1,271 @@
package tests
import (
"encoding/json"
"gestion/db"
"gestion/handlers"
"gestion/models"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
)
// configurePromotionSettings applique les settings donnés (avec Promotions)
// via testDB.UpdateSettings, comme le ferait l'admin — testDB.UpdateSettings
// normalise déjà les slices nil, donc ce helper reste minimal.
func configurePromotionSettings(t *testing.T, settings models.AppSettings) {
t.Helper()
if err := testDB.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
}
// ── Persistance des settings (save→reload) ──────────────────────────────────
func TestUpdateSettings_PromotionsRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{
Category: "test",
DiscountPercent: 15.5,
AllProducts: false,
Products: []models.PromotionProductQuantity{
{ProductID: 111, Quantity: 2},
{ProductID: 222, Quantity: 1},
},
},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
loaded, err := testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if !loaded.PromotionsEnabled {
t.Fatal("promotions_enabled devrait être true après reload")
}
if len(loaded.Promotions) != 1 {
t.Fatalf("promotions: got=%d want=1: %+v", len(loaded.Promotions), loaded.Promotions)
}
promo := loaded.Promotions[0]
if promo.Category != "test" || promo.DiscountPercent != 15.5 {
t.Errorf("promo mal persistée: got=%+v", promo)
}
if len(promo.Products) != 2 || promo.Products[0].ProductID != 111 || promo.Products[0].Quantity != 2 {
t.Errorf("products mal persistés: got=%+v", promo.Products)
}
// Désactivation : doit persister à false, pas de résurrection (même
// classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil).
s.PromotionsEnabled = false
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings (désactivation): %v", err)
}
loaded, err = testDB.GetSettings()
if err != nil {
t.Fatalf("GetSettings (désactivation): %v", err)
}
if loaded.PromotionsEnabled {
t.Error("promotions_enabled devrait rester false après désactivation")
}
}
// ── Résolution de la réduction (logique pure) ───────────────────────────────
func TestResolvePromotionDiscount_MatchesAllProductsAtConfiguredQuantity(t *testing.T) {
settings := &models.AppSettings{
PromotionsEnabled: true,
Promotions: []models.CategoryPromotionConfig{
{Category: "fleurs", DiscountPercent: 20, AllProducts: true, Quantity: 5},
},
}
discount, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 5)
if !ok || discount != 20 {
t.Errorf("got discount=%.2f ok=%v want=20/true", discount, ok)
}
// Mauvaise quantité : pas de promo.
if _, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 3); ok {
t.Error("ne devrait pas matcher une quantité différente de celle configurée")
}
// Mauvaise catégorie : pas de promo.
if _, ok := db.ResolvePromotionDiscount(settings, 42, "autre", 5); ok {
t.Error("ne devrait pas matcher une catégorie différente")
}
}
func TestResolvePromotionDiscount_DisabledReturnsNoDiscount(t *testing.T) {
settings := &models.AppSettings{
PromotionsEnabled: false,
Promotions: []models.CategoryPromotionConfig{
{Category: "fleurs", DiscountPercent: 20, AllProducts: true, Quantity: 5},
},
}
if _, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 5); ok {
t.Error("aucune promo ne doit s'appliquer si promotions_enabled = false")
}
}
func TestResolvePromotionDiscount_PerProductSelection(t *testing.T) {
settings := &models.AppSettings{
PromotionsEnabled: true,
Promotions: []models.CategoryPromotionConfig{
{
Category: "fleurs",
AllProducts: false,
Products: []models.PromotionProductQuantity{
{ProductID: 1, Quantity: 2},
},
DiscountPercent: 10,
},
},
}
if discount, ok := db.ResolvePromotionDiscount(settings, 1, "fleurs", 2); !ok || discount != 10 {
t.Errorf("produit sélectionné à la bonne quantité: got discount=%.2f ok=%v", discount, ok)
}
if _, ok := db.ResolvePromotionDiscount(settings, 1, "fleurs", 3); ok {
t.Error("mauvaise quantité pour ce produit : ne doit pas matcher")
}
if _, ok := db.ResolvePromotionDiscount(settings, 2, "fleurs", 2); ok {
t.Error("produit non sélectionné : ne doit pas matcher")
}
}
// ── AddToBasket applique réellement la réduction au prix facturé ───────────
func TestAddToBasket_AppliesPromotionDiscount(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_basket_applies")
productID := newTestProduct(t, "PromoBasketApplies", 10)
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", DiscountPercent: 20, AllProducts: true, Quantity: 1},
}
configurePromotionSettings(t, s)
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Price != 8.0 {
t.Errorf("prix attendu = 10€ - 20%% = 8.00€: got=%.2f", basket.Price)
}
}
func TestAddToBasket_NoDiscountWhenPromotionsDisabled(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_basket_disabled")
productID := newTestProduct(t, "PromoBasketDisabled", 10)
s := db.DefaultSettings()
s.PromotionsEnabled = false
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", DiscountPercent: 20, AllProducts: true, Quantity: 1},
}
configurePromotionSettings(t, s)
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Price != 10.0 {
t.Errorf("promotions désactivées: le prix catalogue plein doit s'appliquer: got=%.2f want=10.00", basket.Price)
}
}
func TestAddToBasket_NoDiscountForDifferentProductSelection(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_basket_other_product")
promotedID := newTestProduct(t, "PromoBasketOtherPromoted", 10)
otherID := newTestProduct(t, "PromoBasketOtherPlain", 10)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{
Category: "test",
AllProducts: false,
Products: []models.PromotionProductQuantity{{ProductID: promotedID, Quantity: 1}},
DiscountPercent: 50,
},
}
configurePromotionSettings(t, s)
promotedBasket, err := testDB.AddToBasket(username, promotedID, 1)
if err != nil {
t.Fatalf("AddToBasket (promu): %v", err)
}
if promotedBasket.Price != 5.0 {
t.Errorf("produit promu: prix attendu = 10€ - 50%% = 5.00€: got=%.2f", promotedBasket.Price)
}
otherBasket, err := testDB.AddToBasket(username, otherID, 1)
if err != nil {
t.Fatalf("AddToBasket (autre): %v", err)
}
if otherBasket.Price != 10.0 {
t.Errorf("produit non sélectionné dans la promo: prix plein attendu=10.00: got=%.2f", otherBasket.Price)
}
}
// ── Affichage catalogue : le prix promo est annoté sur le palier concerné ──
func TestGetProductByID_AnnotatesPromoPriceOnMatchingTier(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
productID := newTestProduct(t, "PromoDisplayAnnotated", 10)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", DiscountPercent: 25, AllProducts: true, Quantity: 1},
}
configurePromotionSettings(t, s)
idStr := strconv.Itoa(productID)
req := httptest.NewRequest(http.MethodGet, "/api/v1/products/"+idStr, nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Params = gin.Params{{Key: "id", Value: idStr}}
c.Set("role", "client")
handlers.GetProductByID(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Data models.Product `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
if len(resp.Data.Prices) != 1 {
t.Fatalf("attendu 1 palier de prix: got=%+v", resp.Data.Prices)
}
tier := resp.Data.Prices[0]
if tier.PromoPrice == nil {
t.Fatal("PromoPrice devrait être renseigné pour ce palier couvert par la promo")
}
if *tier.PromoPrice != 7.5 {
t.Errorf("promo_price attendu = 10€ - 25%% = 7.50€: got=%.2f", *tier.PromoPrice)
}
if tier.PromoPercent != 25 {
t.Errorf("promo_percent attendu=25: got=%.2f", tier.PromoPercent)
}
}
+187 -28
View File
@@ -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)
}
}
+36
View File
@@ -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) {
@@ -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])
}
}
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -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",
@@ -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-----
+17 -16
View File
@@ -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==
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-----
+2 -2
View File
@@ -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"
}
+22 -8
View File
@@ -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[];
-1
View File
@@ -164,7 +164,6 @@ export const getDeliverymanLocationForCommand = async (commandId: number) => {
// ============================================
// LIVREURS
// ============================================
const parseStatus = (status: any): "available" | "busy" | "offline" => {
if (!status) return "offline";
-1
View File
@@ -123,7 +123,6 @@ export async function calculateRoute(
}
}
// Fallback: straight line
if (coordinates.length === 0) {
coordinates.push(origin, destination);
}
-1
View File
@@ -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,
@@ -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)}
>
<Pressable style={styles.ratingsOverlay} onPress={() => setRatingsModal(null)}>
<Pressable onPress={() => {}}>
<View style={styles.ratingsSheet}>
<View style={styles.ratingsHeader}>
<Text style={styles.ratingsTitle}>
Avis {ratingsModal?.username}
</Text>
<TouchableOpacity onPress={() => setRatingsModal(null)}>
<Ionicons name="close" size={22} color={colors.textMuted} />
</TouchableOpacity>
</View>
{ratingsLoading ? (
<Text style={styles.ratingsEmpty}>Chargement...</Text>
) : ratingsModal && ratingsModal.count > 0 ? (
<>
<View style={styles.ratingsAvg}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= Math.round(ratingsModal.average) ? "star" : "star-outline"}
size={20}
color="#f59e0b"
/>
))}
<Text style={styles.ratingsAvgText}>
{ratingsModal.average.toFixed(1)}
</Text>
<Text style={styles.ratingsCount}>
({ratingsModal.count} avis)
</Text>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{ratingsModal.ratings.map((r) => (
<View key={r.id} style={styles.ratingItem}>
<View style={styles.ratingItemHeader}>
<Text style={styles.ratingItemClient}>{r.client_username}</Text>
<Text style={styles.ratingItemDate}>
{new Date(r.created_at).toLocaleDateString("fr-FR")}
</Text>
</View>
<View style={styles.ratingStarsRow}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= r.rating ? "star" : "star-outline"}
size={14}
color="#f59e0b"
/>
))}
</View>
{r.comment !== "" && (
<Text style={styles.ratingItemComment}>"{r.comment}"</Text>
)}
</View>
))}
</ScrollView>
</>
) : (
<Text style={styles.ratingsEmpty}>Aucun avis pour ce livreur</Text>
)}
<View style={styles.ratingsOverlay}>
<View style={styles.ratingsSheet}>
<View style={styles.ratingsHeader}>
<Text style={styles.ratingsTitle}>
Avis {ratingsModal?.username}
</Text>
<TouchableOpacity onPress={() => setRatingsModal(null)}>
<Ionicons name="close" size={22} color={colors.textMuted} />
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
{ratingsLoading ? (
<Text style={styles.ratingsEmpty}>Chargement...</Text>
) : ratingsModal && ratingsModal.count > 0 ? (
<>
<View style={styles.ratingsAvg}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= Math.round(ratingsModal.average) ? "star" : "star-outline"}
size={20}
color="#f59e0b"
/>
))}
<Text style={styles.ratingsAvgText}>
{ratingsModal.average.toFixed(1)}
</Text>
<Text style={styles.ratingsCount}>
({ratingsModal.count} avis)
</Text>
</View>
<ScrollView style={styles.ratingsList}>
{ratingsModal.ratings.map((r) => (
<View key={r.id} style={styles.ratingItem}>
<View style={styles.ratingItemHeader}>
<Text style={styles.ratingItemClient}>{r.client_username}</Text>
<Text style={styles.ratingItemDate}>
{new Date(r.created_at).toLocaleDateString("fr-FR")}
</Text>
</View>
<View style={styles.ratingStarsRow}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= r.rating ? "star" : "star-outline"}
size={14}
color="#f59e0b"
/>
))}
</View>
{r.comment !== "" && (
<Text style={styles.ratingItemComment}>"{r.comment}"</Text>
)}
</View>
))}
</ScrollView>
</>
) : (
<Text style={styles.ratingsEmpty}>Aucun avis pour ce livreur</Text>
)}
</View>
</View>
</Modal>
{/* ── Modal historique de connexion livreur ── */}
@@ -1148,12 +1148,8 @@ export default function DeliveryScreen() {
animationType="slide"
onRequestClose={() => setLoginHistoryModal(null)}
>
<Pressable
style={styles.ratingsOverlay}
onPress={() => setLoginHistoryModal(null)}
>
<Pressable onPress={() => {}}>
<View style={styles.ratingsSheet}>
<View style={styles.ratingsOverlay}>
<View style={styles.ratingsSheet}>
<View style={styles.ratingsHeader}>
<Text style={styles.ratingsTitle}>
Connexions {loginHistoryModal?.username}
@@ -1258,7 +1254,7 @@ export default function DeliveryScreen() {
</Text>
) : loginHistoryModal &&
loginHistoryModal.weeks.length > 0 ? (
<ScrollView showsVerticalScrollIndicator={false}>
<ScrollView style={styles.ratingsList}>
{loginHistoryModal.weeks.map((week) => (
<View key={week.week}>
<Text style={styles.historyWeekLabel}>
@@ -1310,9 +1306,8 @@ export default function DeliveryScreen() {
Aucune connexion ce mois-ci
</Text>
)}
</View>
</Pressable>
</Pressable>
</View>
</View>
</Modal>
</View>
);
@@ -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 */}
<View style={{ flexDirection: "row", gap: spacing.s }}>
<TouchableOpacity
onPress={() => 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({
</TouchableOpacity>
</View>
{/* Liste des produits si mode sélection */}
{/* Mode "Tous" : une quantité uniforme pour tous les produits de la catégorie */}
{catConfig.all_products && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Quantité :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={catConfig.quantity > 0 ? String(catConfig.quantity) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
onChange({ ...catConfig, quantity: isNaN(n) ? 0 : n });
}}
placeholder="1"
placeholderTextColor={colors.textMuted}
/>
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
doit correspondre à un palier de prix existant
</Text>
</View>
)}
{/* Mode "Sélection" : chaque produit choisi a sa propre quantité
(ex: produit A à 2g offerts, produit B à 1g offert) */}
{!catConfig.all_products && (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
<View style={{ gap: spacing.xs }}>
{catProducts.length === 0 ? (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
Aucun produit dans cette catégorie
</Text>
) : catProducts.map((p) => {
const sel = catConfig.product_ids.includes(p.id);
return (
<TouchableOpacity
key={p.id}
onPress={() => toggleProduct(p.id)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: sel ? REWARD_ACCENT : colors.border,
backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{sel && <Ionicons name="checkmark" size={11} color={REWARD_ACCENT} />}
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
{p.name}
</Text>
</TouchableOpacity>
);
})}
) : (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{catProducts.map((p) => {
const sel = catConfig.products.some((pq) => pq.product_id === p.id);
return (
<TouchableOpacity
key={p.id}
onPress={() => toggleProduct(p.id)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: sel ? REWARD_ACCENT : colors.border,
backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{sel && <Ionicons name="checkmark" size={11} color={REWARD_ACCENT} />}
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
{p.name}
</Text>
</TouchableOpacity>
);
})}
</View>
)}
{catConfig.products.length > 0 && (
<View style={{ gap: spacing.s, marginTop: spacing.xs }}>
{catConfig.products.map((pq) => {
const prod = catProducts.find((p) => p.id === pq.product_id);
const tiers = (prod?.prices ?? []).filter((pr) => pr.active_price !== false);
return (
<View key={pq.product_id} style={{ gap: 4 }}>
<Text style={{ fontSize: 12, color: colors.textMuted }} numberOfLines={1}>
{prod?.name ?? `Produit #${pq.product_id}`}
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4 }}>
{tiers.length === 0 ? (
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
Aucun palier de prix actif pour ce produit
</Text>
) : tiers.map((tier) => {
const isSel = pq.quantity === tier.quantity;
return (
<TouchableOpacity
key={tier.quantity}
onPress={() => updateProductQuantity(pq.product_id, tier.quantity)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 3,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: isSel ? REWARD_ACCENT : colors.border,
backgroundColor: isSel ? REWARD_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{isSel && <Ionicons name="checkmark" size={10} color={REWARD_ACCENT} />}
<Text style={{ fontSize: 11, fontWeight: isSel ? "700" : "400", color: isSel ? REWARD_ACCENT : colors.textMuted }}>
{tier.quantity}{prod?.unit ?? ""} · {tier.price}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
})}
</View>
)}
</View>
)}
</View>
@@ -819,29 +906,44 @@ function CentralRewardSection({
const update = (patch: Partial<PointsReward>) =>
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<Set<string>>(new Set());
const toggleExpanded = (catName: string) => {
setExpandedCats((prev) => {
const next = new Set(prev);
if (next.has(catName)) next.delete(catName); else next.add(catName);
return next;
});
};
const rewardBadge = (
<View style={{
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
@@ -906,141 +1008,13 @@ function CentralRewardSection({
/>
</View>
{/* Produits récompense proposés au client selon le type choisi
pour la catégorie de chaque produit (voir "Catégories éligibles" ci-dessous) */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Produits ajoutés au panier</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
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.
</Text>
<View style={{ gap: spacing.s }}>
{r.reward_items.map((item, idx) => {
const allProds = Object.values(productsByCategory).flat();
const prod = allProds.find((p) => p.id === item.product_id);
return (
<View
key={idx}
style={{
borderWidth: 1, borderColor: REWARD_ACCENT + "44",
borderRadius: borderRadius.sm, padding: spacing.m,
backgroundColor: REWARD_ACCENT + "08", gap: spacing.s,
}}
>
{/* Sélecteur produit */}
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{allProds.map((p) => {
const sel = item.product_id === p.id;
return (
<TouchableOpacity
key={p.id}
onPress={() => {
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 && <Ionicons name="checkmark" size={11} color={REWARD_ACCENT} />}
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
{p.name} ({p.category})
</Text>
</TouchableOpacity>
);
})}
</View>
{/* Quantité + Prix + Supprimer */}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Qté :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={item.quantity > 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}
/>
</View>
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Prix :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={item.price > 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}
/>
<Text style={{ fontSize: 12, color: colors.textMuted }}></Text>
</View>
<TouchableOpacity
onPress={() => update({ reward_items: r.reward_items.filter((_, i) => i !== idx) })}
style={{ padding: spacing.xs }}
>
<Ionicons name="trash-outline" size={18} color="#ef4444" />
</TouchableOpacity>
</View>
{prod && (
<Text style={{ fontSize: 11, color: REWARD_ACCENT, fontStyle: "italic" }}>
{prod.name}{item.quantity > 0 ? ` · x${item.quantity}` : ""}{item.price > 0 ? ` · ${item.price}` : ""}
</Text>
)}
{!prod && (
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
Sélectionnez un produit ci-dessus
</Text>
)}
</View>
);
})}
{Object.values(productsByCategory).flat().length === 0 ? (
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucun produit disponible</Text>
) : (
<TouchableOpacity
onPress={() => 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",
}}
>
<Ionicons name="add-circle-outline" size={16} color={REWARD_ACCENT} />
<Text style={{ fontSize: 13, color: REWARD_ACCENT, fontWeight: "600" }}>Ajouter un produit récompense</Text>
</TouchableOpacity>
)}
</View>
</View>
{/* 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 */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories éligibles</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
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.
</Text>
{allCategories.length === 0 ? (
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
@@ -1048,12 +1022,13 @@ function CentralRewardSection({
<View style={{ gap: spacing.m }}>
{allCategories.map((cat) => {
const selected = isCatSelected(cat.name);
const expanded = expandedCats.has(cat.name);
const catColor = cat.color || REWARD_ACCENT;
return (
<View key={cat.name}>
{/* Chip catégorie */}
{/* Chip catégorie (couleur = au moins un type actif, clic = déplier/replier) */}
<TouchableOpacity
onPress={() => 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}
</Text>
<Ionicons
name={selected ? "chevron-down" : "chevron-forward"}
name={expanded ? "chevron-down" : "chevron-forward"}
size={12}
color={selected ? catColor : colors.textMuted}
/>
</TouchableOpacity>
{/* Type + sélecteur produits (visible si catégorie sélectionnée) */}
{selected && (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{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 && (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.m }}>
{REWARD_TYPES.map((rt) => {
const typeEnabled = isTypeEnabled(cat.name, rt.value);
return (
<View key={rt.value} style={{ gap: spacing.s }}>
<TouchableOpacity
key={rt.value}
onPress={() => 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",
}}
>
<Ionicons name={rt.icon as any} size={12} color={sel ? REWARD_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
<Ionicons
name={typeEnabled ? "checkbox" : "square-outline"}
size={14}
color={typeEnabled ? REWARD_ACCENT : colors.textMuted}
/>
<Ionicons name={rt.icon as any} size={12} color={typeEnabled ? REWARD_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: typeEnabled ? "700" : "400", color: typeEnabled ? REWARD_ACCENT : colors.textMuted }}>
{rt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
<CategoryProductPicker
catConfig={getCatConfig(cat.name)}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
{typeEnabled && (
<CategoryProductPicker
catConfig={getCatConfig(cat.name, rt.value)}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
)}
</View>
);
})}
</View>
)}
</View>
@@ -1118,7 +1099,7 @@ function CentralRewardSection({
</View>
{/* Récapitulatif */}
{(r.category_configs.length > 0 || r.reward_items.filter((it) => it.product_id > 0).length > 0) && (
{r.category_configs.length > 0 && (
<View style={{ backgroundColor: REWARD_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: REWARD_ACCENT, padding: spacing.m, gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "700", color: REWARD_ACCENT }}>Récapitulatif</Text>
<Text style={{ fontSize: 13, color: colors.textPrimary }}>
@@ -1127,19 +1108,196 @@ function CentralRewardSection({
{r.description !== "" && (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>"{r.description}"</Text>
)}
{r.category_configs.map((cfg) => (
<Text key={cfg.category} style={{ fontSize: 12, color: colors.textSecondary }}>
{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) => (
<Text key={`${cfg.category}-${cfg.type}-${idx}`} style={{ fontSize: 12, color: colors.textSecondary }}>
{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(", ")}`}
</Text>
))}
{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);
</View>
)}
</View>
)}
</AccordionSection>
);
}
// ──────────────────────────────────────────────────────────────
// Sélecteur de produits pour une catégorie dans une promotion —
// même logique que CategoryProductPicker (récompenses), sans notion
// de "type" : une seule réduction (%) par catégorie.
// ──────────────────────────────────────────────────────────────
const PROMO_ACCENT = "#22c55e";
function PromotionProductPicker({
catConfig,
products,
onChange,
colors,
s,
}: {
catConfig: CategoryPromotionConfig;
products: Product[];
onChange: (cfg: CategoryPromotionConfig) => void;
colors: any;
s: any;
}) {
const catProducts = products.filter((p) => p.category === catConfig.category);
const toggleProduct = (id: number) => {
const exists = catConfig.products.some((pq) => pq.product_id === id);
if (exists) {
onChange({ ...catConfig, products: catConfig.products.filter((pq) => pq.product_id !== id), all_products: false });
return;
}
// Présélectionne le premier palier de prix actif du produit.
const prod = catProducts.find((p) => p.id === id);
const firstTier = (prod?.prices ?? []).find((pr) => pr.active_price !== false);
onChange({
...catConfig,
products: [...catConfig.products, { product_id: id, quantity: firstTier?.quantity ?? 0 }],
all_products: false,
});
};
const updateProductQuantity = (id: number, quantity: number) => {
onChange({
...catConfig,
products: catConfig.products.map((pq) =>
pq.product_id === id ? { ...pq, quantity } : pq
),
});
};
return (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
{/* Toggle tous / sélection */}
<View style={{ flexDirection: "row", gap: spacing.s }}>
<TouchableOpacity
onPress={() => onChange({ ...catConfig, all_products: true, products: [] })}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: catConfig.all_products ? PROMO_ACCENT : colors.border,
backgroundColor: catConfig.all_products ? PROMO_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="checkmark-done-outline" size={13} color={catConfig.all_products ? PROMO_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: catConfig.all_products ? "700" : "400", color: catConfig.all_products ? PROMO_ACCENT : colors.textMuted }}>
Tous ({catProducts.length})
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onChange({ ...catConfig, all_products: false })}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: !catConfig.all_products ? PROMO_ACCENT : colors.border,
backgroundColor: !catConfig.all_products ? PROMO_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="list-outline" size={13} color={!catConfig.all_products ? PROMO_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: !catConfig.all_products ? "700" : "400", color: !catConfig.all_products ? PROMO_ACCENT : colors.textMuted }}>
Sélection
</Text>
</TouchableOpacity>
</View>
{/* Mode "Tous" : une quantité uniforme pour tous les produits de la catégorie */}
{catConfig.all_products && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Quantité :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={catConfig.quantity > 0 ? String(catConfig.quantity) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
onChange({ ...catConfig, quantity: isNaN(n) ? 0 : n });
}}
placeholder="1"
placeholderTextColor={colors.textMuted}
/>
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
doit correspondre à un palier de prix existant
</Text>
</View>
)}
{/* Mode "Sélection" : chaque produit choisi a sa propre quantité,
via les paliers de prix réels du produit (pas de saisie libre) */}
{!catConfig.all_products && (
<View style={{ gap: spacing.xs }}>
{catProducts.length === 0 ? (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
Aucun produit dans cette catégorie
</Text>
) : (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{catProducts.map((p) => {
const sel = catConfig.products.some((pq) => pq.product_id === p.id);
return (
<View key={idx} style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Ionicons name="gift-outline" size={11} color={REWARD_ACCENT} />
<Text style={{ fontSize: 12, color: colors.textSecondary }}>
{prod?.name ?? `Produit #${it.product_id}`}{it.quantity > 0 ? ` · x${it.quantity}` : ""}{it.price > 0 ? ` · ${it.price}` : ""}
<TouchableOpacity
key={p.id}
onPress={() => toggleProduct(p.id)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: sel ? PROMO_ACCENT : colors.border,
backgroundColor: sel ? PROMO_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{sel && <Ionicons name="checkmark" size={11} color={PROMO_ACCENT} />}
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? PROMO_ACCENT : colors.textMuted }}>
{p.name}
</Text>
</TouchableOpacity>
);
})}
</View>
)}
{catConfig.products.length > 0 && (
<View style={{ gap: spacing.s, marginTop: spacing.xs }}>
{catConfig.products.map((pq) => {
const prod = catProducts.find((p) => p.id === pq.product_id);
const tiers = (prod?.prices ?? []).filter((pr) => pr.active_price !== false);
return (
<View key={pq.product_id} style={{ gap: 4 }}>
<Text style={{ fontSize: 12, color: colors.textMuted }} numberOfLines={1}>
{prod?.name ?? `Produit #${pq.product_id}`}
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4 }}>
{tiers.length === 0 ? (
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
Aucun palier de prix actif pour ce produit
</Text>
) : tiers.map((tier) => {
const isSel = pq.quantity === tier.quantity;
return (
<TouchableOpacity
key={tier.quantity}
onPress={() => updateProductQuantity(pq.product_id, tier.quantity)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 3,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: isSel ? PROMO_ACCENT : colors.border,
backgroundColor: isSel ? PROMO_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{isSel && <Ionicons name="checkmark" size={10} color={PROMO_ACCENT} />}
<Text style={{ fontSize: 11, fontWeight: isSel ? "700" : "400", color: isSel ? PROMO_ACCENT : colors.textMuted }}>
{tier.quantity}{prod?.unit ?? ""} · {tier.price}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
})}
@@ -1147,6 +1305,204 @@ function CentralRewardSection({
)}
</View>
)}
</View>
);
}
// ──────────────────────────────────────────────────────────────
// Section centralisée promotions — réduction (%) automatique sur des
// produits/quantités d'une catégorie, appliquée à toute commande
// (indépendant des points de fidélité, contrairement aux récompenses).
// ──────────────────────────────────────────────────────────────
function PromotionsSection({
enabled,
promotions,
allCategories,
productsByCategory,
onToggle,
onChangePromotions,
colors,
s,
}: {
enabled: boolean;
promotions: CategoryPromotionConfig[];
allCategories: Category[];
productsByCategory: Record<string, Product[]>;
onToggle: (v: boolean) => void;
onChangePromotions: (promotions: CategoryPromotionConfig[]) => void;
colors: any;
s: any;
}) {
const getCatConfig = (catName: string): CategoryPromotionConfig =>
promotions.find((p) => p.category === catName) ??
{ category: catName, discount_percent: 10, all_products: true, quantity: 1, products: [] };
const isCatSelected = (catName: string) => promotions.some((p) => p.category === catName);
const toggleCategory = (catName: string) => {
if (isCatSelected(catName)) {
onChangePromotions(promotions.filter((p) => p.category !== catName));
} else {
onChangePromotions([...promotions, { category: catName, discount_percent: 10, all_products: true, quantity: 1, products: [] }]);
}
};
const updateCatConfig = (cfg: CategoryPromotionConfig) => {
onChangePromotions(promotions.map((p) => (p.category === cfg.category ? cfg : p)));
};
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set());
const toggleExpanded = (catName: string) => {
setExpandedCats((prev) => {
const next = new Set(prev);
if (next.has(catName)) next.delete(catName); else next.add(catName);
return next;
});
};
const promoBadge = (
<View style={{
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
backgroundColor: enabled ? PROMO_ACCENT + "25" : colors.border + "40",
borderWidth: 1, borderColor: enabled ? PROMO_ACCENT : colors.border,
}}>
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? PROMO_ACCENT : colors.textMuted }}>
{enabled ? "Activées" : "Désactivées"}
</Text>
</View>
);
return (
<AccordionSection title="Promotions" badge={promoBadge} colors={colors} s={s}>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Promotions activées</Text>
<Text style={s.rowDesc}>
Réduction automatique appliquée au prix affiché et facturé, pour tout client indépendant des points de fidélité.
</Text>
</View>
<Switch
value={enabled}
onValueChange={onToggle}
trackColor={{ false: colors.border, true: PROMO_ACCENT }}
thumbColor="#fff"
/>
</View>
{enabled && (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.l, gap: spacing.l }}>
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories en promo</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
Sélectionnez une catégorie, définissez le pourcentage de réduction, puis tous les produits ou une sélection avec leur quantité.
</Text>
{allCategories.length === 0 ? (
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
) : (
<View style={{ gap: spacing.m }}>
{allCategories.map((cat) => {
const selected = isCatSelected(cat.name);
const expanded = expandedCats.has(cat.name);
const catColor = cat.color || PROMO_ACCENT;
const cfg = getCatConfig(cat.name);
return (
<View key={cat.name}>
<TouchableOpacity
onPress={() => toggleExpanded(cat.name)}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
alignSelf: "flex-start",
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: selected ? catColor : colors.border,
backgroundColor: selected ? catColor + "22" : "transparent",
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: catColor }} />
<Text style={{ fontSize: 13, fontWeight: selected ? "700" : "400", color: selected ? catColor : colors.textMuted }}>
{cat.name}{selected ? ` · -${cfg.discount_percent}%` : ""}
</Text>
<Ionicons
name={expanded ? "chevron-down" : "chevron-forward"}
size={12}
color={selected ? catColor : colors.textMuted}
/>
</TouchableOpacity>
{expanded && (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
<TouchableOpacity
onPress={() => toggleCategory(cat.name)}
style={{
flexDirection: "row", alignItems: "center", gap: 4,
alignSelf: "flex-start",
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: selected ? PROMO_ACCENT : colors.border,
backgroundColor: selected ? PROMO_ACCENT + "22" : "transparent",
}}
>
<Ionicons
name={selected ? "checkbox" : "square-outline"}
size={14}
color={selected ? PROMO_ACCENT : colors.textMuted}
/>
<Ionicons name="pricetag-outline" size={12} color={selected ? PROMO_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: selected ? "700" : "400", color: selected ? PROMO_ACCENT : colors.textMuted }}>
Promo active sur cette catégorie
</Text>
</TouchableOpacity>
{selected && (
<>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Réduction :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={cfg.discount_percent > 0 ? String(cfg.discount_percent) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
updateCatConfig({ ...cfg, discount_percent: isNaN(n) ? 0 : n });
}}
placeholder="10"
placeholderTextColor={colors.textMuted}
/>
<Text style={{ fontSize: 12, color: colors.textMuted }}>%</Text>
</View>
<PromotionProductPicker
catConfig={cfg}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
</>
)}
</View>
)}
</View>
);
})}
</View>
)}
</View>
{/* Récapitulatif */}
{promotions.length > 0 && (
<View style={{ backgroundColor: PROMO_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: PROMO_ACCENT, padding: spacing.m, gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "700", color: PROMO_ACCENT }}>Récapitulatif</Text>
{promotions.map((cfg, idx) => (
<Text key={`${cfg.category}-${idx}`} style={{ fontSize: 12, color: colors.textSecondary }}>
{cfg.category} -{cfg.discount_percent}% sur {cfg.all_products
? `tous les produits · qté ${cfg.quantity > 0 ? cfg.quantity : 1}`
: `${cfg.products.length} produit(s) : ${cfg.products.map((pq) => `#${pq.product_id}×${pq.quantity}`).join(", ")}`}
</Text>
))}
</View>
)}
</View>
)}
</AccordionSection>
);
}
@@ -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 */}
<PromotionsSection
enabled={settings.promotions_enabled ?? false}
promotions={settings.promotions ?? []}
allCategories={categories}
productsByCategory={productsByCategory}
onToggle={(v) => setSettings((p) => ({ ...p, promotions_enabled: v }))}
onChangePromotions={(promotions) => setSettings((p) => ({ ...p, promotions }))}
colors={colors}
s={s}
/>
{/* Horaires de livraison */}
<DeliveryScheduleSection
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
+8 -3
View File
@@ -2166,13 +2166,18 @@ export const unlinkTelegram = async (): Promise<void> => {
// 🏆 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 = {
+2
View File
@@ -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;
@@ -452,18 +452,18 @@ function ConsultationHistorique() {
</span>,
]
: (
cfg.product_names ??
cfg.products ??
[]
).map(
(
name,
p,
) => (
<span
key={`${cfg.category}-${name}`}
key={`${cfg.category}-${p.product_id}`}
className="reward-eligible-cat"
>
{
name
p.product_name
}
</span>
),
+41 -11
View File
@@ -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() {
<div className="product-info-section">
<h1 className="product-detail-name">{product.name}</h1>
{selectedPrice > 0 && (
<p className="product-detail-price">
{selectedPrice.toFixed(2)} {" "}
{selectedGrams &&
`pour ${selectedGrams}${product.unit || "g"}`}
</p>
)}
{selectedPrice > 0 && (() => {
const selectedTier = product.prices?.find(
(p) => p.quantity === selectedGrams,
);
const hasPromo =
selectedTier?.promo_price != null &&
selectedTier.promo_price < selectedTier.price;
return (
<p className="product-detail-price">
{hasPromo && (
<span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8 }}>
{selectedTier!.price.toFixed(2)}
</span>
)}
<span style={hasPromo ? { color: "#22c55e" } : undefined}>
{selectedPrice.toFixed(2)}
</span>{" "}
{selectedGrams &&
`pour ${selectedGrams}${product.unit || "g"}`}
{hasPromo && ` (-${selectedTier!.promo_percent}%)`}
</p>
);
})()}
<div className="product-description">
<h3>Description</h3>
@@ -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)}`}
</option>
))}
</select>
+1
View File
@@ -30,6 +30,7 @@ yarn-error.*
.DS_Store
*.pem
!certs/certificate.pem
!certs/certificate-preprod.pem
# local env files
.env*.local
+2 -2
View File
@@ -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",
+19
View File
@@ -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-----
+17 -16
View File
@@ -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==
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-----
+2 -2
View File
@@ -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"
}
+8 -4
View File
@@ -529,7 +529,6 @@ export const getOrdersWithTracking = async () => {
// ============================================
// HISTORY
// ============================================
export const getMyCompletedOrders = async (): Promise<HistoryResponse> => {
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 = {
+2
View File
@@ -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;
-1
View File
@@ -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,
@@ -830,14 +830,14 @@ export default function OrderHistoryScreen() {
</View>,
]
: (
cfg.product_names ??
cfg.products ??
[]
).map(
(
name,
p,
) => (
<View
key={`${cfg.category}-${name}`}
key={`${cfg.category}-${p.product_id}`}
style={
styles.rewardAmountBadge
}
@@ -848,7 +848,7 @@ export default function OrderHistoryScreen() {
}
>
{
name
p.product_name
}
</Text>
</View>
@@ -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() {
<View style={styles.infoSection}>
<Text style={styles.productName}>{product.name}</Text>
{selectedPrice > 0 && (
<View style={styles.priceRow}>
<View style={styles.priceIndicator} />
<Text style={styles.priceText}>
{selectedPrice.toFixed(2)} {" "}
{selectedGrams &&
`pour ${selectedGrams}${product.unit || "g"}`}
</Text>
</View>
)}
{selectedPrice > 0 && (() => {
const selectedTier = product.prices?.find(
(p) => p.quantity === selectedGrams,
);
const hasPromo =
selectedTier?.promo_price != null &&
selectedTier.promo_price < selectedTier.price;
return (
<View style={styles.priceRow}>
<View style={styles.priceIndicator} />
{hasPromo && (
<Text
style={[
styles.priceText,
{
textDecorationLine: "line-through",
opacity: 0.6,
marginRight: 6,
},
]}
>
{selectedTier!.price.toFixed(2)}
</Text>
)}
<Text
style={[
styles.priceText,
hasPromo && { color: "#22c55e" },
]}
>
{selectedPrice.toFixed(2)} {" "}
{selectedGrams &&
`pour ${selectedGrams}${product.unit || "g"}`}
{hasPromo &&
` (-${selectedTier!.promo_percent}%)`}
</Text>
</View>
);
})()}
<View style={styles.descriptionCard}>
<Text style={styles.descriptionTitle}>Description</Text>
<Text style={styles.descriptionText}>
@@ -719,16 +754,32 @@ export default function ProductDetailScreen() {
{p.quantity}
{product.unit || "g"}
</Text>
<Text
style={[
styles.pickerOptionPrice,
selectedGrams === p.quantity && {
color: catColor,
},
]}
>
{p.price.toFixed(2)}
</Text>
{p.promo_price != null && p.promo_price < p.price ? (
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Text
style={[
styles.pickerOptionPrice,
{ textDecorationLine: "line-through", opacity: 0.6 },
]}
>
{p.price.toFixed(2)}
</Text>
<Text style={[styles.pickerOptionPrice, { color: "#22c55e" }]}>
{p.promo_price.toFixed(2)} (-{p.promo_percent}%)
</Text>
</View>
) : (
<Text
style={[
styles.pickerOptionPrice,
selectedGrams === p.quantity && {
color: catColor,
},
]}
>
{p.price.toFixed(2)}
</Text>
)}
</View>
{selectedGrams === p.quantity && (
<View
@@ -34,7 +34,6 @@ const logoGrosSemi = require("../../../assets/logo-gros-semi.png");
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const CARD_WIDTH = SCREEN_WIDTH - 48;
// Les catégories sont chargées dynamiquement depuis l'API
type Nav = NativeStackNavigationProp<ClientStackParamList>;