6 Commits
Author SHA1 Message Date
Xor290 d9688acbc2 chore: build
Backend - Build & Lint / build (push) Canceled after 24m25s
Frontend Admin - EAS Build / build (push) Successful in 1h35m12s
2026-09-13 22:07:04 +02:00
Xor290 d7d7496a66 chore: build
Backend - Build & Lint / build (push) Successful in 32m38s
Frontend Admin - EAS Build / build (push) Successful in 1h34m28s
Frontend Client - EAS Build / build (push) Successful in 1h30m42s
Frontend Web - Build & Lint / build (push) Canceled after 12m2s
2026-09-13 16:11:30 +02:00
Xor290 f2f537a194 chore: build 2026-09-13 16:11:26 +02:00
Xor290 91745636ec chore: build 2026-09-13 16:11:22 +02:00
Xor290 181147e3bf chore: build 2026-09-10 18:53:29 +02:00
Xor290 624df79974 chore: build 2026-09-08 17:33:32 +02:00
37 changed files with 3205 additions and 156 deletions
+40 -17
View File
@@ -130,12 +130,12 @@ 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 {
return fmt.Errorf("erreur lecture stock: %w", err)
var productInfo struct {
Stock float64 `gorm:"column:stock"`
Category string `gorm:"column:category"`
}
if currentStock < quantity {
return fmt.Errorf("stock insuffisant")
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)
}
var priceResult struct {
@@ -148,28 +148,51 @@ 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). Le montant
// économisé est conservé (promoDiscount) pour les statistiques
// admin, indépendamment de la config de promo courante au moment où
// ces stats seront consultées.
var promoDiscount float64
if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok {
promoDiscount = priceResult.Price - discounted
priceResult.Price = discounted
}
// Offre "achetez X, Y offert" : le client reçoit une quantité
// supplémentaire du même produit, gratuite, sans changer le prix déjà
// calculé sur la quantité demandée — la quantité livrée/décomptée du
// stock est donc supérieure à la quantité facturée.
freeQuantity := d.ResolveFreeGiftQuantity(productID, productInfo.Category, quantity)
deliveredQuantity := quantity + freeQuantity
if productInfo.Stock < deliveredQuantity {
return fmt.Errorf("stock insuffisant")
}
var existing struct {
ID int `gorm:"column:id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
ID int `gorm:"column:id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
PromoDiscount float64 `gorm:"column:promo_discount"`
}
// Chercher uniquement un item normal (non-récompense) pour ce produit
tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
tx.Raw(`SELECT id, quantity, price, promo_discount FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
username, productID).Scan(&existing)
if existing.ID != 0 {
return tx.Raw(`
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
existing.Quantity+quantity, existing.Price+priceResult.Price,
existing.ID).Scan(&basket).Error
UPDATE baskets SET quantity = ?, price = ?, promo_discount = ?, created_at = CURRENT_TIMESTAMP
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
existing.PromoDiscount+promoDiscount, existing.ID).Scan(&basket).Error
}
return tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
username, productID, quantity, priceResult.Price).Scan(&basket).Error
INSERT INTO baskets (username, product_id, quantity, price, is_reward, promo_discount, created_at)
VALUES (?, ?, ?, ?, false, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
username, productID, deliveredQuantity, priceResult.Price, promoDiscount).Scan(&basket).Error
})
if err != nil {
return nil, err
+7 -2
View File
@@ -19,6 +19,7 @@ type commandItemFull struct {
Prix float64 `gorm:"column:prix"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
PromoDiscount float64 `gorm:"column:promo_discount"`
ClientUsername string `gorm:"column:client_username"`
ClientNom string `gorm:"column:client_nom"`
ClientPrenom string `gorm:"column:client_prenom"`
@@ -232,6 +233,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
ProductID *int64 `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
PromoDiscount float64 `gorm:"column:promo_discount"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
ClientUsername string `gorm:"column:client_username"`
@@ -261,6 +263,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
ci.product_id,
ci.quantite,
ci.prix,
ci.promo_discount,
ci.is_reward,
ci.reward_pool_key,
ci.client_username,
@@ -309,6 +312,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
"product_id": productIDValue,
"quantite": row.Quantite,
"prix": row.Prix,
"promo_discount": row.PromoDiscount,
"is_reward": row.IsReward,
"reward_pool_key": row.RewardPoolKey,
"client_username": row.ClientUsername,
@@ -352,6 +356,7 @@ func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]
ProductID *int64 `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
PromoDiscount float64 `gorm:"column:promo_discount"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
ClientUsername string `gorm:"column:client_username"`
@@ -376,7 +381,7 @@ func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]
err := d.GDB.Raw(`
SELECT
ci.id, ci.command_id, ci.produit, ci.product_id,
ci.quantite, ci.prix, ci.is_reward, ci.reward_pool_key,
ci.quantite, ci.prix, ci.promo_discount, ci.is_reward, ci.reward_pool_key,
ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone,
ci.delivery_address, ci.status, ci.created_at, ci.updated_at,
c.status as command_status, c.adresse as command_address,
@@ -406,7 +411,7 @@ func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]
item := map[string]any{
"id": row.ID, "command_id": row.CommandID,
"produit": row.Produit, "product_id": productIDValue,
"quantite": row.Quantite, "prix": row.Prix,
"quantite": row.Quantite, "prix": row.Prix, "promo_discount": row.PromoDiscount,
"is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey,
"client_username": row.ClientUsername, "client_nom": row.ClientNom,
"client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone,
+3 -1
View File
@@ -55,6 +55,7 @@ type basketItem struct {
Price float64 `gorm:"column:price"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
PromoDiscount float64 `gorm:"column:promo_discount"`
}
// validateCommandStatus vérifie si le statut est valide
@@ -109,7 +110,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
// bloque ici puis échoue proprement ("panier vide") une fois le premier
// passage terminé, au lieu de créer une commande fantôme.
var basketItems []basketItem
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key, promo_discount FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
return fmt.Errorf("erreur récupération panier: %w", err)
}
if len(basketItems) == 0 {
@@ -162,6 +163,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
Prix: item.Price,
IsReward: item.IsReward,
RewardPoolKey: item.RewardPoolKey,
PromoDiscount: item.PromoDiscount,
ClientUsername: username,
ClientNom: clientNom,
ClientPrenom: clientPrenom,
+59
View File
@@ -0,0 +1,59 @@
package db
import "gestion/models"
// ResolveFreeGift retourne la quantité offerte (du même produit) pour un
// produit, sa catégorie catalogue et une quantité commandée donnés — le seuil
// le plus élevé (BuyQuantity) atteint par la quantité commandée est retenu,
// tous seuils confondus pour ce produit (ex: seuils 10g→+1g et 20g→+3g, une
// commande de 25g retient +3g, pas +1g).
func ResolveFreeGift(settings *models.AppSettings, productID int, category string, quantity float64) float64 {
if settings == nil || !settings.FreeGiftsEnabled {
return 0
}
var bestBuy, bestFree float64
found := false
consider := func(tiers []models.FreeGiftTier) {
for _, t := range tiers {
if t.BuyQuantity <= 0 || t.FreeQuantity <= 0 || quantity < t.BuyQuantity {
continue
}
if !found || t.BuyQuantity > bestBuy {
bestBuy, bestFree = t.BuyQuantity, t.FreeQuantity
found = true
}
}
}
for _, g := range settings.FreeGifts {
if g.Category != category {
continue
}
if g.AllProducts {
consider(g.Tiers)
continue
}
for _, pq := range g.Products {
if pq.ProductID == productID {
consider(pq.Tiers)
}
}
}
if !found {
return 0
}
return bestFree
}
// ResolveFreeGiftQuantity lit les settings courants et applique
// ResolveFreeGift — wrapper pratique pour les appelants qui n'ont pas déjà
// les settings sous la main (même style que ApplyPromotionToPrice).
func (d *Database) ResolveFreeGiftQuantity(productID int, category string, quantity float64) float64 {
settings, err := d.GetSettings()
if err != nil {
return 0
}
return ResolveFreeGift(&settings, productID, category, quantity)
}
+14
View File
@@ -140,6 +140,20 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
}
// Migration: baskets.promo_discount + command_items.promo_discount —
// montant (en €) économisé par une promotion de prix sur cette ligne,
// capturé une fois pour toutes au moment de AddToBasket (voir
// db_basket.go) puis copié tel quel au checkout, pour permettre des
// statistiques historiques fiables même si la config de promo change
// ensuite (contrairement à un recalcul a posteriori sur les settings
// courants, qui donnerait un résultat faux pour les anciennes commandes).
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration baskets.promo_discount: %v", err)
}
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration command_items.promo_discount: %v", err)
}
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
if _, err = database.Exec(`
DO $$
+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
}
+52
View File
@@ -142,6 +142,20 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
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 "free_gifts_enabled":
settings.FreeGiftsEnabled = row.Value == "true"
case "free_gifts":
var freeGifts []models.CategoryFreeGiftConfig
if err := json.Unmarshal([]byte(row.Value), &freeGifts); err == nil {
settings.FreeGifts = freeGifts
}
case "referral_enabled":
settings.ReferralEnabled = row.Value == "true"
case "referral_amount":
@@ -263,6 +277,40 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
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.FreeGifts == nil {
s.FreeGifts = []models.CategoryFreeGiftConfig{}
}
for i := range s.FreeGifts {
if s.FreeGifts[i].Tiers == nil {
s.FreeGifts[i].Tiers = []models.FreeGiftTier{}
}
if s.FreeGifts[i].Products == nil {
s.FreeGifts[i].Products = []models.FreeGiftProductQuantity{}
}
for j := range s.FreeGifts[i].Products {
if s.FreeGifts[i].Products[j].Tiers == nil {
s.FreeGifts[i].Products[j].Tiers = []models.FreeGiftTier{}
}
}
}
freeGiftsJSON, err := json.Marshal(s.FreeGifts)
if err != nil {
return fmt.Errorf("erreur sérialisation free_gifts: %w", err)
}
if s.NowPaymentsCurrencies == nil {
s.NowPaymentsCurrencies = []string{}
}
@@ -302,6 +350,10 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
{"points_enabled", boolStr(s.PointsEnabled)},
{"points_pools", string(poolsJSON)},
{"points_reward", string(rewardJSON)},
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
{"promotions", string(promotionsJSON)},
{"free_gifts_enabled", boolStr(s.FreeGiftsEnabled)},
{"free_gifts", string(freeGiftsJSON)},
{"referral_enabled", boolStr(s.ReferralEnabled)},
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
+33
View File
@@ -379,6 +379,39 @@ func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
return total, err
}
// TotalPromoDiscount renvoie le montant total (€) des réductions de prix
// accordées par des promotions sur les commandes approuvées, filtré par le
// reset "revenus" (même périmètre que TotalRevenue, dont c'est un
// sous-indicateur). Basé sur command_items.promo_discount, capturé au moment
// de AddToBasket — reflète donc les promos réellement appliquées à l'époque,
// pas la config de promotions courante.
func (d *Database) TotalPromoDiscount(resetAt time.Time) (float64, error) {
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
var total float64
query := `
SELECT COALESCE(SUM(ci.promo_discount), 0)
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
WHERE ` + where
err := d.GDB.Raw(query, args...).Scan(&total).Error
return total, err
}
// PromoOrdersCount renvoie le nombre de commandes distinctes (approuvées)
// ayant bénéficié d'au moins une réduction de prix promo, filtré par le
// reset "revenus".
func (d *Database) PromoOrdersCount(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
var count int64
query := `
SELECT COUNT(DISTINCT ci.command_id)
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
WHERE ci.promo_discount > 0 AND ` + where
err := d.GDB.Raw(query, args...).Scan(&count).Error
return count, err
}
// ActiveDaysLast30 renvoie le nombre de jours distincts ayant eu au moins une commande sur 30 jours.
func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
+10 -8
View File
@@ -73,10 +73,11 @@ func GetMyDeliveries(c *gin.Context) {
itemsSummary := make([]gin.H, len(items))
for j, item := range items {
itemsSummary[j] = gin.H{
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
"is_reward": item["is_reward"],
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
"promo_discount": item["promo_discount"],
"is_reward": item["is_reward"],
}
}
@@ -155,10 +156,11 @@ func GetDeliveryDetails(c *gin.Context) {
itemsSummary := make([]gin.H, len(items))
for i, item := range items {
itemsSummary[i] = gin.H{
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
"is_reward": item["is_reward"],
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
"promo_discount": item["promo_discount"],
"is_reward": item["is_reward"],
}
}
+10
View File
@@ -375,6 +375,16 @@ func ClaimMyReward(c *gin.Context) {
}
}
// Sans produit éligible pour ce pool (ex: catégories de la récompense mal
// alignées avec celles du pool), on refuse avant de consommer un point —
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
// jamais ajouté au panier (récompense perdue silencieusement).
if len(itemsToAdd) == 0 {
log.Printf("❌ [CLAIM] Aucun produit éligible pour %s (pool=%s)", username, req.PoolKey)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
return
}
remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd)
if err != nil {
if strings.Contains(err.Error(), "pas de récompense disponible") {
+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]
}
+31 -17
View File
@@ -204,18 +204,20 @@ func GetAdminStats(c *gin.Context) {
// Toutes les requêtes sont indépendantes — on les lance en parallèle.
var (
wdRows []models.WeekdayRow
dayRows []models.DayRow
dayRevRows []models.DayRevenueRow
hourRows []models.HourRow
prodRows []models.ProductRow
qtyRows []models.QuantityBreakdownRow
dailyRows []models.DailyProductRow
totalOrders int64
totalRevenue float64
dailyTotalOrders int64
activeDays int64
last30Count int64
wdRows []models.WeekdayRow
dayRows []models.DayRow
dayRevRows []models.DayRevenueRow
hourRows []models.HourRow
prodRows []models.ProductRow
qtyRows []models.QuantityBreakdownRow
dailyRows []models.DailyProductRow
totalOrders int64
totalRevenue float64
totalPromoDiscount float64
promoOrdersCount int64
dailyTotalOrders int64
activeDays int64
last30Count int64
)
eg, _ := errgroup.WithContext(context.Background())
@@ -236,6 +238,16 @@ func GetAdminStats(c *gin.Context) {
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
totalPromoDiscount, err = database.TotalPromoDiscount(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
promoOrdersCount, err = database.PromoOrdersCount(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
dailyTotalOrders, err = database.DailyOrdersCount()
@@ -430,11 +442,13 @@ func GetAdminStats(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"summary": gin.H{
"total_orders": totalOrders,
"total_revenue": totalRevenue,
"peak_weekday": peakWeekday,
"top_product": topProductName,
"avg_per_day": avgPerDay,
"total_orders": totalOrders,
"total_revenue": totalRevenue,
"total_promo_discount": totalPromoDiscount,
"promo_orders_count": promoOrdersCount,
"peak_weekday": peakWeekday,
"top_product": topProductName,
"avg_per_day": avgPerDay,
},
"reset_at_commandes": dateFilter(filters.ResetCommandes),
"reset_at_revenus": dateFilter(filters.ResetRevenus),
+11 -10
View File
@@ -19,16 +19,17 @@ func (Command) TableName() string { return "commandes" }
// CommandItem représente un produit dans une commande
type CommandItem struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
CommandID int `gorm:"column:command_id" json:"command_id"`
Produit string `gorm:"column:produit" json:"produit"`
ProductID int `gorm:"column:product_id" json:"product_id"`
Quantity float64 `gorm:"column:quantite" json:"quantity"`
Price float64 `gorm:"column:prix" json:"price"`
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
CommandID int `gorm:"column:command_id" json:"command_id"`
Produit string `gorm:"column:produit" json:"produit"`
ProductID int `gorm:"column:product_id" json:"product_id"`
Quantity float64 `gorm:"column:quantite" json:"quantity"`
Price float64 `gorm:"column:prix" json:"price"`
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
PromoDiscount float64 `gorm:"column:promo_discount" json:"promo_discount,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
type CommandLog struct {
+13 -12
View File
@@ -3,16 +3,17 @@ package models
import "time"
type Panier struct {
ID int `json:"id"`
Username string `json:"username"`
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
IsReward bool `json:"is_reward"`
RewardPoolKey string `json:"reward_pool_key,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
ID int `json:"id"`
Username string `json:"username"`
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
IsReward bool `json:"is_reward"`
RewardPoolKey string `json:"reward_pool_key,omitempty"`
PromoDiscount float64 `json:"promo_discount,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
+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" }
+86 -22
View File
@@ -59,6 +59,66 @@ type PointsReward struct {
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
}
// FreeGiftTier définit un seuil d'achat et la quantité offerte associée, du
// même produit — plusieurs seuils peuvent coexister pour un même produit
// (ex: 10g achetés → 1g offert, 20g achetés → 3g offerts) ; le seuil le plus
// élevé atteint par la quantité commandée est retenu (voir ResolveFreeGift).
type FreeGiftTier struct {
BuyQuantity float64 `json:"buy_quantity"` // quantité à acheter pour déclencher l'offre
FreeQuantity float64 `json:"free_quantity"` // quantité offerte du même produit
}
// FreeGiftProductQuantity associe un produit à ses propres seuils
// d'achat/offre, pour le cas où une catégorie n'est pas configurée en "tous
// les produits" — même logique que PromotionProductQuantity mais pour les
// offres quantité achetée/offerte.
type FreeGiftProductQuantity struct {
ProductID int `json:"product_id"`
Tiers []FreeGiftTier `json:"tiers"`
}
// CategoryFreeGiftConfig définit une offre "achetez X, Y offert" (du même
// produit) appliquée automatiquement dès que la quantité ajoutée au panier
// atteint un seuil configuré — indépendant des points de fidélité et des
// promotions (cumulable avec elles).
//
// Si AllProducts = true, Tiers s'applique uniformément à tous les produits de
// la catégorie. Si AllProducts = false, chaque produit sélectionné dans
// Products a ses propres seuils (Tiers au niveau catégorie est alors ignoré).
type CategoryFreeGiftConfig struct {
Category string `json:"category"` // nom de la catégorie
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
Tiers []FreeGiftTier `json:"tiers"` // seuils uniformes si AllProducts = true
Products []FreeGiftProductQuantity `json:"products"` // produits + seuils individuels si AllProducts = false
}
// DaySchedule représente les horaires de livraison pour un jour de la semaine
type DaySchedule struct {
Enabled bool `json:"enabled"`
@@ -106,28 +166,32 @@ 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
FreeGiftsEnabled bool `json:"free_gifts_enabled"` // activer/désactiver les offres "achetez X, Y offert"
FreeGifts []CategoryFreeGiftConfig `json:"free_gifts"` // offres quantité achetée/offerte 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"`
@@ -0,0 +1,135 @@
package tests
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
func myDeliveriesContext(username, status string) (*gin.Context, *httptest.ResponseRecorder) {
url := "/api/v1/livreur/deliveries"
if status != "" {
url += "?status=" + status
}
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", username)
c.Set("role", "livreur")
return c, rec
}
// Le livreur doit voir qu'un article a bénéficié d'une promotion de prix
// (promo_discount > 0), pour pouvoir justifier au client un montant total
// inférieur au prix catalogue — voir GetDeliveryDetails/GetMyDeliveries
// (backend/gestion/handlers/deleviry.go) et GetCommandItems/GetCommandItemsBatch
// (backend/gestion/db/db_command_items.go).
func TestGetDeliveryDetails_ExposesPromoDiscountPerItem(t *testing.T) {
cleanupStockTestData(t)
client := newTestClient(t, "delivpromo_client")
livreur := newTestClient(t, "delivpromo_livreur")
productID := newTestProduct(t, "DelivPromoDiscount", 20)
// 3g normalement à 50€, facturés 25€ (-50%) : promo_discount = 25€.
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 3, 25)
if err := testDB.GDB.Exec(
`UPDATE command_items SET promo_discount = 25 WHERE command_id = ? AND product_id = ?`,
cmdID, productID,
).Error; err != nil {
t.Fatalf("mise à jour promo_discount: %v", err)
}
c, rec := deliveryDetailsContext(livreur, cmdID)
handlers.GetDeliveryDetails(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Success bool `json:"success"`
Delivery struct {
Items []struct {
Produit string `json:"produit"`
Prix float64 `json:"prix"`
PromoDiscount float64 `json:"promo_discount"`
} `json:"items"`
} `json:"delivery"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
if !resp.Success || len(resp.Delivery.Items) != 1 {
t.Fatalf("réponse inattendue: body=%s", rec.Body.String())
}
item := resp.Delivery.Items[0]
if item.PromoDiscount != 25 {
t.Errorf("promo_discount doit être exposé au livreur: got=%.2f want=25.00 (body=%s)", item.PromoDiscount, rec.Body.String())
}
if item.Prix != 25 {
t.Errorf("le prix affiché doit rester le prix déjà réduit facturé: got=%.2f want=25.00", item.Prix)
}
}
// Même vérification côté GetMyDeliveries (liste des livraisons), qui passe
// par un chemin de requête différent (GetCommandItemsBatch) que
// GetDeliveryDetails (GetCommandItems).
func TestGetMyDeliveries_ExposesPromoDiscountPerItem(t *testing.T) {
cleanupStockTestData(t)
client := newTestClient(t, "delivpromo_list_client")
livreur := newTestClient(t, "delivpromo_list_livreur")
productID := newTestProduct(t, "DelivPromoListDiscount", 20)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 3, 25)
if err := testDB.GDB.Exec(
`UPDATE command_items SET promo_discount = 25 WHERE command_id = ? AND product_id = ?`,
cmdID, productID,
).Error; err != nil {
t.Fatalf("mise à jour promo_discount: %v", err)
}
c, rec := myDeliveriesContext(livreur, "")
handlers.GetMyDeliveries(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Success bool `json:"success"`
Deliveries []struct {
ID int `json:"id"`
Items []struct {
PromoDiscount float64 `json:"promo_discount"`
} `json:"items"`
} `json:"deliveries"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
if !resp.Success {
t.Fatalf("réponse non successful: body=%s", rec.Body.String())
}
var found bool
for _, d := range resp.Deliveries {
if d.ID != cmdID {
continue
}
if len(d.Items) != 1 || d.Items[0].PromoDiscount != 25 {
t.Fatalf("promo_discount doit être exposé dans GetMyDeliveries: %+v", d.Items)
}
found = true
}
if !found {
t.Fatalf("commande %d introuvable dans la réponse: body=%s", cmdID, rec.Body.String())
}
}
+395
View File
@@ -0,0 +1,395 @@
package tests
import (
"gestion/db"
"gestion/models"
"testing"
)
// ── Persistance des settings (save→reload) ──────────────────────────────────
func TestUpdateSettings_FreeGiftsRoundTrip(t *testing.T) {
resetSettingsAfterTest(t)
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{
Category: "test",
AllProducts: false,
Products: []models.FreeGiftProductQuantity{
{ProductID: 111, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
{BuyQuantity: 20, FreeQuantity: 3},
}},
},
},
}
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.FreeGiftsEnabled {
t.Fatal("free_gifts_enabled devrait être true après reload")
}
if len(loaded.FreeGifts) != 1 {
t.Fatalf("free_gifts: got=%d want=1: %+v", len(loaded.FreeGifts), loaded.FreeGifts)
}
gift := loaded.FreeGifts[0]
if gift.Category != "test" || len(gift.Products) != 1 {
t.Fatalf("free gift mal persistée: got=%+v", gift)
}
if len(gift.Products[0].Tiers) != 2 || gift.Products[0].Tiers[1].BuyQuantity != 20 || gift.Products[0].Tiers[1].FreeQuantity != 3 {
t.Errorf("tiers mal persistés: got=%+v", gift.Products[0].Tiers)
}
// Désactivation : doit persister à false, pas de résurrection (même
// classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil).
s.FreeGiftsEnabled = 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.FreeGiftsEnabled {
t.Error("free_gifts_enabled devrait rester false après désactivation")
}
}
// ── Résolution de la quantité offerte (logique pure) ────────────────────────
func TestResolveFreeGift_AllProductsAtOrAboveThreshold(t *testing.T) {
settings := &models.AppSettings{
FreeGiftsEnabled: true,
FreeGifts: []models.CategoryFreeGiftConfig{
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
},
}
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 1 {
t.Errorf("quantité offerte: got=%.2f want=1", got)
}
if got := db.ResolveFreeGift(settings, 42, "fleurs", 15); got != 1 {
t.Errorf("au-dessus du seuil, le cadeau reste dû: got=%.2f want=1", got)
}
if got := db.ResolveFreeGift(settings, 42, "fleurs", 9); got != 0 {
t.Errorf("sous le seuil, aucun cadeau: got=%.2f want=0", got)
}
}
func TestResolveFreeGift_DisabledReturnsZero(t *testing.T) {
settings := &models.AppSettings{
FreeGiftsEnabled: false,
FreeGifts: []models.CategoryFreeGiftConfig{
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
},
}
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 0 {
t.Errorf("offres désactivées: aucun cadeau attendu: got=%.2f", got)
}
}
func TestResolveFreeGift_PerProductHighestTierApplies(t *testing.T) {
settings := &models.AppSettings{
FreeGiftsEnabled: true,
FreeGifts: []models.CategoryFreeGiftConfig{
{
Category: "fleurs",
AllProducts: false,
Products: []models.FreeGiftProductQuantity{
{ProductID: 111, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
{BuyQuantity: 20, FreeQuantity: 3},
}},
},
},
},
}
if got := db.ResolveFreeGift(settings, 111, "fleurs", 10); got != 1 {
t.Errorf("seuil 10g: got=%.2f want=1", got)
}
// 25g dépasse les deux seuils : le plus élevé (20g→3g) doit être retenu,
// pas le premier de la liste (10g→1g).
if got := db.ResolveFreeGift(settings, 111, "fleurs", 25); got != 3 {
t.Errorf("seuil le plus élevé atteint (20g→3g): got=%.2f want=3", got)
}
// Produit non listé dans cette config : aucun cadeau.
if got := db.ResolveFreeGift(settings, 222, "fleurs", 25); got != 0 {
t.Errorf("produit non couvert: got=%.2f want=0", got)
}
}
// ── Intégration AddToBasket : la quantité livrée inclut le cadeau, au même prix ──
func TestAddToBasket_AppliesFreeGiftQuantityAtSamePrice(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_basket_applies")
productID := newTestProduct(t, "FreeGiftBasketApplies", 50)
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
basket, err := testDB.AddToBasket(username, productID, 10)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Quantity != 11 {
t.Errorf("quantité livrée attendue = 10 + 1 offert = 11: got=%.2f", basket.Quantity)
}
if basket.Price != 10.0 {
t.Errorf("le prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", basket.Price)
}
}
func TestAddToBasket_NoFreeGiftBelowThreshold(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_basket_below")
productID := newTestProduct(t, "FreeGiftBasketBelow", 50)
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
basket, err := testDB.AddToBasket(username, productID, 5)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Quantity != 5 {
t.Errorf("sous le seuil, aucune quantité offerte: got=%.2f want=5", basket.Quantity)
}
}
// La quantité réellement décomptée du stock doit inclure le cadeau : un stock
// suffisant pour la quantité demandée mais pas pour demandée+offerte doit
// faire échouer l'ajout, pas livrer un cadeau partiel.
func TestAddToBasket_FreeGiftRejectedWhenStockInsufficientForBonus(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_basket_stock")
productID := newTestProduct(t, "FreeGiftBasketStock", 10) // stock = 10, pile la quantité demandée
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 10); err == nil {
t.Fatal("stock=10 ne doit pas suffire pour livrer 10g + 1g offert")
}
}
// ── Intégration checkout : le bonus offert est bien décompté du stock ──────
//
// AddToBasket stocke déjà quantity = demandée + offerte (voir tests
// ci-dessus) ; CreateCommandWithAddress ne relit ni ne recalcule cette
// quantité — elle est copiée telle quelle dans command_items.quantite et
// utilisée telle quelle pour décrémenter products.stock (db_commands.go).
// Ces tests vérifient ce chemin de bout en bout, pas juste AddToBasket isolé.
func TestCheckout_FreeGiftBonusQuantityDecrementsStock(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_checkout_stock")
productID := newTestProduct(t, "FreeGiftCheckoutStock", 50)
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
// 50 initial - (10 demandés + 1 offert) = 39, pas 40.
if got := productStock(t, productID); got != 39 {
t.Errorf("stock après checkout avec cadeau: got=%.2f want=39 (50 - 11)", got)
}
var item struct {
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
}
if err := testDB.GDB.Raw(
`SELECT quantite, prix FROM command_items WHERE command_id = ? AND product_id = ?`,
cmd.ID, productID,
).Scan(&item).Error; err != nil {
t.Fatalf("lecture command_items: %v", err)
}
if item.Quantite != 11 {
t.Errorf("command_items.quantite doit inclure le cadeau: got=%.2f want=11", item.Quantite)
}
if item.Prix != 10.0 {
t.Errorf("command_items.prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", item.Prix)
}
}
func TestCheckout_FreeGiftRollsBackWhenStockInsufficientForBonus(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_checkout_rollback")
productID := newTestProduct(t, "FreeGiftCheckoutRollback", 50)
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
// Le stock chute sous 11 (10 demandés + 1 offert) après l'ajout au panier,
// simulant une vente concurrente qui vide le stock entre AddToBasket et
// checkout — le checkout doit échouer et ne rien décrémenter.
if err := testDB.GDB.Exec(`UPDATE products SET stock = 10 WHERE id = ?`, productID).Error; err != nil {
t.Fatalf("réduction stock: %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil {
t.Fatal("checkout attendu en échec: stock=10 insuffisant pour 10 demandés + 1 offert")
}
if got := productStock(t, productID); got != 10 {
t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=10", got)
}
}
// ── Intégration annulation : le remboursement inclut le bonus offert ───────
func TestCancelCommandAtomic_RefundsFreeGiftBonusQuantity(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_cancel_refund")
productID := newTestProduct(t, "FreeGiftCancelRefund", 50)
s := db.DefaultSettings()
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
if got := productStock(t, productID); got != 39 {
t.Fatalf("précondition stock post-checkout: got=%.2f want=39", got)
}
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err != nil {
t.Fatalf("CancelCommandAtomic: %v", err)
}
// 39 + 11 (10 demandés + 1 offert) = 50, retour exact au stock initial.
if got := productStock(t, productID); got != 50 {
t.Errorf("stock après annulation (bonus offert inclus dans le remboursement): got=%.2f want=50", got)
}
// Rejeu : ne doit rembourser qu'une fois.
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err == nil {
t.Fatal("le second appel sur une commande déjà annulée doit échouer, pas rembourser une seconde fois")
}
if got := productStock(t, productID); got != 50 {
t.Errorf("stock après double annulation: got=%.2f want=50 (un seul remboursement)", got)
}
}
// ── Cumul avec les promotions de prix ───────────────────────────────────────
//
// Une offre "achetez X, Y offert" et une promotion de réduction (%) sur le
// même produit doivent pouvoir s'appliquer ensemble : la promotion réduit le
// prix facturé sur la quantité demandée, le cadeau ajoute de la quantité
// livrée sans toucher au prix — les deux mécanismes sont indépendants dans
// AddToBasket (voir db_basket.go) mais rien ne garantissait jusqu'ici qu'ils
// ne s'écrasent pas mutuellement une fois combinés.
func TestAddToBasket_FreeGiftAndPromotionBothApplyTogether(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "freegift_promo_combo")
productID := newTestProduct(t, "FreeGiftPromoCombo", 50)
// 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", AllProducts: true, Quantity: 10, DiscountPercent: 20},
}
s.FreeGiftsEnabled = true
s.FreeGifts = []models.CategoryFreeGiftConfig{
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
{BuyQuantity: 10, FreeQuantity: 1},
}},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
basket, err := testDB.AddToBasket(username, productID, 10)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Quantity != 11 {
t.Errorf("le cadeau doit s'appliquer malgré la promo active: got quantity=%.2f want=11", basket.Quantity)
}
if basket.Price != 8.0 {
t.Errorf("la promo doit s'appliquer malgré le cadeau actif: got price=%.2f want=8.00 (10€ - 20%%)", basket.Price)
}
}
+308
View File
@@ -0,0 +1,308 @@
package tests
import (
"gestion/db"
"gestion/models"
"testing"
"time"
)
// ── Traçage du montant économisé (AddToBasket → command_items) ─────────────
//
// command_items.promo_discount / baskets.promo_discount capturent le montant
// (€) économisé par une promotion de prix au moment de AddToBasket, pour
// permettre des statistiques historiques fiables même si la configuration de
// promotion change ensuite (voir db_basket.go, commentaire sur promoDiscount).
func TestAddToBasket_TracksPromoDiscountAmount(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_discount_track")
productID := newTestProduct(t, "PromoDiscountTrack", 20)
// 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", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.Price != 8.0 {
t.Fatalf("précondition prix promo: got=%.2f want=8.00", basket.Price)
}
if basket.PromoDiscount != 2.0 {
t.Errorf("promo_discount doit être l'écart catalogue/promo: got=%.2f want=2.00 (10€-8€)", basket.PromoDiscount)
}
}
func TestAddToBasket_NoPromoDiscountWithoutPromotion(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_discount_none")
productID := newTestProduct(t, "PromoDiscountNone", 20)
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if basket.PromoDiscount != 0 {
t.Errorf("sans promo, promo_discount doit rester à 0: got=%.2f", basket.PromoDiscount)
}
}
// Deux ajouts successifs du même produit (même ligne panier, is_reward=false)
// fusionnent quantité et prix (voir AddToBasket) — promo_discount doit être
// cumulé de la même façon, pas remplacé par le dernier ajout.
func TestAddToBasket_MergePromoDiscountAccumulatesAcrossAdds(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_discount_merge")
productID := newTestProduct(t, "PromoDiscountMerge", 20)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket (1er ajout): %v", err)
}
basket, err := testDB.AddToBasket(username, productID, 1)
if err != nil {
t.Fatalf("AddToBasket (2e ajout): %v", err)
}
if basket.PromoDiscount != 4.0 {
t.Errorf("le cumul des deux ajouts doit sommer les remises: got=%.2f want=4.00 (2×2€)", basket.PromoDiscount)
}
}
func TestCheckout_PromoDiscountCopiedToCommandItems(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "promo_discount_checkout")
productID := newTestProduct(t, "PromoDiscountCheckout", 20)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
var discount float64
if err := testDB.GDB.Raw(
`SELECT promo_discount FROM command_items WHERE command_id = ? AND product_id = ?`,
cmd.ID, productID,
).Scan(&discount).Error; err != nil {
t.Fatalf("lecture command_items: %v", err)
}
if discount != 2.0 {
t.Errorf("promo_discount doit être copié tel quel au checkout: got=%.2f want=2.00", discount)
}
}
// ── Stats admin : total économisé et nombre de commandes concernées ────────
func approveTestCommand(t *testing.T, commandID int) {
t.Helper()
if err := testDB.GDB.Exec(`UPDATE commandes SET status = 'approved' WHERE id = ?`, commandID).Error; err != nil {
t.Fatalf("passage en approved: %v", err)
}
}
func TestTotalPromoDiscount_SumsOnlyApprovedOrders(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "stats_promo_discount")
productID := newTestProduct(t, "StatsPromoDiscount", 20)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
// Commande 1 : avec promo, approuvée → comptée.
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket (cmd1): %v", err)
}
cmd1, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress (cmd1): %v", err)
}
approveTestCommand(t, cmd1.ID)
// Commande 2 : avec promo, restée "pending" (statut par défaut du
// checkout) → NE DOIT PAS être comptée.
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket (cmd2): %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
t.Fatalf("CreateCommandWithAddress (cmd2): %v", err)
}
total, err := testDB.TotalPromoDiscount(time.Time{})
if err != nil {
t.Fatalf("TotalPromoDiscount: %v", err)
}
if total != 2.0 {
t.Errorf("seule la commande approuvée doit compter: got=%.2f want=2.00", total)
}
count, err := testDB.PromoOrdersCount(time.Time{})
if err != nil {
t.Fatalf("PromoOrdersCount: %v", err)
}
if count != 1 {
t.Errorf("une seule commande approuvée avec promo: got=%d want=1", count)
}
}
func TestTotalPromoDiscount_RespectsResetFilter(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "stats_promo_reset")
productID := newTestProduct(t, "StatsPromoReset", 20)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
approveTestCommand(t, cmd.ID)
// Un reset postérieur à la création de la commande doit l'exclure — sert
// aussi à vérifier que la jointure command_items/commandes qualifie bien
// created_at par l'alias (les deux tables ont une colonne created_at,
// donc une clause non qualifiée provoquerait une erreur Postgres
// "ambiguous column" plutôt qu'un mauvais résultat).
future := time.Now().Add(time.Hour)
total, err := testDB.TotalPromoDiscount(future)
if err != nil {
t.Fatalf("TotalPromoDiscount: %v", err)
}
if total != 0 {
t.Errorf("commande antérieure au reset: doit être exclue: got=%.2f want=0", total)
}
count, err := testDB.PromoOrdersCount(future)
if err != nil {
t.Fatalf("PromoOrdersCount: %v", err)
}
if count != 0 {
t.Errorf("commande antérieure au reset: doit être exclue: got=%d want=0", count)
}
}
// Une commande avec plusieurs lignes en promo ne doit compter qu'une fois
// dans PromoOrdersCount (COUNT DISTINCT command_id), mais le montant total
// doit sommer toutes les lignes.
func TestPromoOrdersCount_CountsOrderOnceDespiteMultipleDiscountedItems(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "stats_promo_multi")
productA := newTestProduct(t, "StatsPromoMultiA", 20)
productB := newTestProduct(t, "StatsPromoMultiB", 20)
s := db.DefaultSettings()
s.PromotionsEnabled = true
s.Promotions = []models.CategoryPromotionConfig{
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
}
if err := testDB.UpdateSettings(s); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if _, err := testDB.AddToBasket(username, productA, 1); err != nil {
t.Fatalf("AddToBasket A: %v", err)
}
if _, err := testDB.AddToBasket(username, productB, 1); err != nil {
t.Fatalf("AddToBasket B: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
approveTestCommand(t, cmd.ID)
count, err := testDB.PromoOrdersCount(time.Time{})
if err != nil {
t.Fatalf("PromoOrdersCount: %v", err)
}
if count != 1 {
t.Errorf("une commande avec 2 lignes en promo doit compter une seule fois: got=%d want=1", count)
}
total, err := testDB.TotalPromoDiscount(time.Time{})
if err != nil {
t.Fatalf("TotalPromoDiscount: %v", err)
}
if total != 4.0 {
t.Errorf("le montant total doit sommer les deux lignes: got=%.2f want=4.00 (2×2€)", total)
}
}
func TestPromoOrdersCount_IgnoresOrdersWithoutDiscount(t *testing.T) {
cleanupStockTestData(t)
resetSettingsAfterTest(t)
username := newTestClient(t, "stats_promo_zero")
productID := newTestProduct(t, "StatsPromoZero", 20)
// Aucune promotion configurée : promo_discount reste à 0 pour cette commande.
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
approveTestCommand(t, cmd.ID)
count, err := testDB.PromoOrdersCount(time.Time{})
if err != nil {
t.Fatalf("PromoOrdersCount: %v", err)
}
if count != 0 {
t.Errorf("aucune commande sans promo ne doit être comptée: got=%d want=0", count)
}
total, err := testDB.TotalPromoDiscount(time.Time{})
if err != nil {
t.Fatalf("TotalPromoDiscount: %v", err)
}
if total != 0 {
t.Errorf("aucun montant économisé sans promo: got=%.2f want=0", total)
}
}
+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)
}
}
@@ -224,6 +224,51 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T)
}
}
// Si les catégories configurées sur la récompense ne correspondent à aucune
// catégorie du pool réclamé (erreur de configuration admin : pool assigné à
// "test", récompense configurée sur "other"), la liste de produits éligibles
// est vide et la réclamation doit échouer avant de consommer un point —
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
// jamais ajouté au panier (régression couverte : la récompense était
// auparavant "consommée" silencieusement sans rien livrer).
func TestClaimMyReward_HTTPFlow_RejectsWhenNoEligibleItemsForPoolCategories(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_no_eligible")
newTestProduct(t, "RewardHTTPNoEligible", 5)
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
CategoryConfigs: []models.RewardCategoryConfig{
{Category: "other", Type: "free_product", AllProducts: true, Quantity: 1},
},
})
setClientPoolPoints(t, username, "pool_0", 25)
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
c, rec := claimRewardContext(username, body)
handlers.ClaimMyReward(c)
if rec.Code != http.StatusConflict {
t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusConflict, rec.Body.String())
}
points, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 0 {
t.Errorf("la récompense ne doit PAS être consommée si aucun produit n'est éligible: got redeemed=%d want=0", redeemed["pool_0"])
}
if points["pool_0"] != 25 {
t.Errorf("les points accumulés ne doivent pas être touchés: got=%d want=25", points["pool_0"])
}
rows := basketRewardItems(t, username)
if len(rows) != 0 {
t.Errorf("aucun produit récompense ne doit être ajouté au panier: %+v", rows)
}
}
// 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
@@ -1,9 +1,16 @@
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 —
@@ -304,3 +311,80 @@ func TestUpdateSettings_ColorAndGradientFieldsRoundTrip(t *testing.T) {
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])
}
}
+36
View File
@@ -56,6 +56,8 @@ export const logoutAdmin = async (): Promise<void> => {
export interface StatsSummary {
total_orders: number;
total_revenue: number;
total_promo_discount: number;
promo_orders_count: number;
peak_weekday: string;
top_product: string;
avg_per_day: number;
@@ -1126,6 +1128,36 @@ export interface PointsReward {
category_configs: RewardCategoryConfig[];
}
export interface PromotionProductQuantity {
product_id: number;
quantity: number; // quantité individuelle de ce produit (palier de prix catalogue)
}
export interface CategoryPromotionConfig {
category: string;
discount_percent: number; // pourcentage de réduction libre (ex: 10, 20, 33.5)
all_products: boolean;
quantity: number; // quantité uniforme si all_products = true
products: PromotionProductQuantity[]; // produits + quantité individuelle si all_products = false
}
export interface FreeGiftTier {
buy_quantity: number; // quantité à acheter pour déclencher l'offre
free_quantity: number; // quantité offerte du même produit
}
export interface FreeGiftProductQuantity {
product_id: number;
tiers: FreeGiftTier[]; // seuils propres à ce produit
}
export interface CategoryFreeGiftConfig {
category: string;
all_products: boolean;
tiers: FreeGiftTier[]; // seuils uniformes si all_products = true
products: FreeGiftProductQuantity[]; // produits + seuils individuels si all_products = false
}
export interface PointsPool {
key: string;
name: string;
@@ -1225,6 +1257,10 @@ export interface AppSettings {
points_enabled: boolean;
points_pools: PointsPool[];
points_reward?: PointsReward | null;
promotions_enabled: boolean;
promotions: CategoryPromotionConfig[];
free_gifts_enabled: boolean;
free_gifts: CategoryFreeGiftConfig[];
referral_enabled: boolean;
delivery_schedule: DeliverySchedule;
postal_zones: PostalZone[];
@@ -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, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, CategoryPromotionConfig, CategoryFreeGiftConfig, FreeGiftProductQuantity, FreeGiftTier, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
import type { Product } from "../../api/types";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
@@ -1123,6 +1123,802 @@ function CentralRewardSection({
);
}
// ──────────────────────────────────────────────────────────────
// Sélecteur de produits pour une catégorie dans une promotion —
// même logique que CategoryProductPicker (récompenses), sans notion
// de "type" : une seule réduction (%) par catégorie.
// ──────────────────────────────────────────────────────────────
const PROMO_ACCENT = "#22c55e";
function PromotionProductPicker({
catConfig,
products,
onChange,
colors,
s,
}: {
catConfig: CategoryPromotionConfig;
products: Product[];
onChange: (cfg: CategoryPromotionConfig) => void;
colors: any;
s: any;
}) {
const catProducts = products.filter((p) => p.category === catConfig.category);
const toggleProduct = (id: number) => {
const exists = catConfig.products.some((pq) => pq.product_id === id);
if (exists) {
onChange({ ...catConfig, products: catConfig.products.filter((pq) => pq.product_id !== id), all_products: false });
return;
}
// Présélectionne le premier palier de prix actif du produit.
const prod = catProducts.find((p) => p.id === id);
const firstTier = (prod?.prices ?? []).find((pr) => pr.active_price !== false);
onChange({
...catConfig,
products: [...catConfig.products, { product_id: id, quantity: firstTier?.quantity ?? 0 }],
all_products: false,
});
};
// Chaque produit peut être en promo sur plusieurs paliers de quantité en
// même temps (ex: 1g ET 3g) — on ajoute/retire l'entrée {product_id,
// quantity} correspondante plutôt que de remplacer une quantité unique.
const toggleProductQuantity = (id: number, quantity: number) => {
const exists = catConfig.products.some((pq) => pq.product_id === id && pq.quantity === quantity);
if (exists) {
onChange({
...catConfig,
products: catConfig.products.filter((pq) => !(pq.product_id === id && pq.quantity === quantity)),
});
} else {
onChange({
...catConfig,
products: [...catConfig.products, { product_id: id, quantity }],
});
}
};
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 peut être en promo sur
plusieurs paliers de quantité à la fois, via les paliers de
prix réels du produit (pas de saisie libre) */}
{!catConfig.all_products && (
<View style={{ gap: spacing.xs }}>
{catProducts.length === 0 ? (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
Aucun produit dans cette catégorie
</Text>
) : (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{catProducts.map((p) => {
const sel = catConfig.products.some((pq) => pq.product_id === p.id);
return (
<TouchableOpacity
key={p.id}
onPress={() => toggleProduct(p.id)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: sel ? PROMO_ACCENT : colors.border,
backgroundColor: sel ? PROMO_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{sel && <Ionicons name="checkmark" size={11} color={PROMO_ACCENT} />}
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? PROMO_ACCENT : colors.textMuted }}>
{p.name}
</Text>
</TouchableOpacity>
);
})}
</View>
)}
{catConfig.products.length > 0 && (
<View style={{ gap: spacing.s, marginTop: spacing.xs }}>
{Array.from(new Set(catConfig.products.map((pq) => pq.product_id))).map((productId) => {
const prod = catProducts.find((p) => p.id === productId);
const tiers = (prod?.prices ?? []).filter((pr) => pr.active_price !== false);
const selectedQuantities = catConfig.products
.filter((pq) => pq.product_id === productId)
.map((pq) => pq.quantity);
return (
<View key={productId} style={{ gap: 4 }}>
<Text style={{ fontSize: 12, color: colors.textMuted }} numberOfLines={1}>
{prod?.name ?? `Produit #${productId}`}
</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 = selectedQuantities.includes(tier.quantity);
return (
<TouchableOpacity
key={tier.quantity}
onPress={() => toggleProductQuantity(productId, tier.quantity)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 3,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: isSel ? PROMO_ACCENT : colors.border,
backgroundColor: isSel ? PROMO_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{isSel && <Ionicons name="checkmark" size={10} color={PROMO_ACCENT} />}
<Text style={{ fontSize: 11, fontWeight: isSel ? "700" : "400", color: isSel ? PROMO_ACCENT : colors.textMuted }}>
{tier.quantity}{prod?.unit ?? ""} · {tier.price}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
})}
</View>
)}
</View>
)}
</View>
);
}
// ──────────────────────────────────────────────────────────────
// Section centralisée promotions — réduction (%) automatique sur des
// produits/quantités d'une catégorie, appliquée à toute commande
// (indépendant des points de fidélité, contrairement aux récompenses).
// ──────────────────────────────────────────────────────────────
function PromotionsSection({
enabled,
promotions,
allCategories,
productsByCategory,
onToggle,
onChangePromotions,
colors,
s,
}: {
enabled: boolean;
promotions: CategoryPromotionConfig[];
allCategories: Category[];
productsByCategory: Record<string, Product[]>;
onToggle: (v: boolean) => void;
onChangePromotions: (promotions: CategoryPromotionConfig[]) => void;
colors: any;
s: any;
}) {
const getCatConfig = (catName: string): CategoryPromotionConfig =>
promotions.find((p) => p.category === catName) ??
{ category: catName, discount_percent: 10, all_products: true, quantity: 1, products: [] };
const isCatSelected = (catName: string) => promotions.some((p) => p.category === catName);
const toggleCategory = (catName: string) => {
if (isCatSelected(catName)) {
onChangePromotions(promotions.filter((p) => p.category !== catName));
} else {
onChangePromotions([...promotions, { category: catName, discount_percent: 10, all_products: true, quantity: 1, products: [] }]);
}
};
const updateCatConfig = (cfg: CategoryPromotionConfig) => {
onChangePromotions(promotions.map((p) => (p.category === cfg.category ? cfg : p)));
};
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set());
const toggleExpanded = (catName: string) => {
setExpandedCats((prev) => {
const next = new Set(prev);
if (next.has(catName)) next.delete(catName); else next.add(catName);
return next;
});
};
const promoBadge = (
<View style={{
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
backgroundColor: enabled ? PROMO_ACCENT + "25" : colors.border + "40",
borderWidth: 1, borderColor: enabled ? PROMO_ACCENT : colors.border,
}}>
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? PROMO_ACCENT : colors.textMuted }}>
{enabled ? "Activées" : "Désactivées"}
</Text>
</View>
);
return (
<AccordionSection title="Promotions" badge={promoBadge} colors={colors} s={s}>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Promotions activées</Text>
<Text style={s.rowDesc}>
Réduction automatique appliquée au prix affiché et facturé, pour tout client indépendant des points de fidélité.
</Text>
</View>
<Switch
value={enabled}
onValueChange={onToggle}
trackColor={{ false: colors.border, true: PROMO_ACCENT }}
thumbColor="#fff"
/>
</View>
{enabled && (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.l, gap: spacing.l }}>
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories en promo</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
Sélectionnez une catégorie, définissez le pourcentage de réduction, puis tous les produits ou une sélection avec leur quantité.
</Text>
{allCategories.length === 0 ? (
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
) : (
<View style={{ gap: spacing.m }}>
{allCategories.map((cat) => {
const selected = isCatSelected(cat.name);
const expanded = expandedCats.has(cat.name);
const catColor = cat.color || PROMO_ACCENT;
const cfg = getCatConfig(cat.name);
return (
<View key={cat.name}>
<TouchableOpacity
onPress={() => toggleExpanded(cat.name)}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
alignSelf: "flex-start",
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: selected ? catColor : colors.border,
backgroundColor: selected ? catColor + "22" : "transparent",
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: catColor }} />
<Text style={{ fontSize: 13, fontWeight: selected ? "700" : "400", color: selected ? catColor : colors.textMuted }}>
{cat.name}{selected ? ` · -${cfg.discount_percent}%` : ""}
</Text>
<Ionicons
name={expanded ? "chevron-down" : "chevron-forward"}
size={12}
color={selected ? catColor : colors.textMuted}
/>
</TouchableOpacity>
{expanded && (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
<TouchableOpacity
onPress={() => toggleCategory(cat.name)}
style={{
flexDirection: "row", alignItems: "center", gap: 4,
alignSelf: "flex-start",
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: selected ? PROMO_ACCENT : colors.border,
backgroundColor: selected ? PROMO_ACCENT + "22" : "transparent",
}}
>
<Ionicons
name={selected ? "checkbox" : "square-outline"}
size={14}
color={selected ? PROMO_ACCENT : colors.textMuted}
/>
<Ionicons name="pricetag-outline" size={12} color={selected ? PROMO_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: selected ? "700" : "400", color: selected ? PROMO_ACCENT : colors.textMuted }}>
Promo active sur cette catégorie
</Text>
</TouchableOpacity>
{selected && (
<>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Réduction :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={cfg.discount_percent > 0 ? String(cfg.discount_percent) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
updateCatConfig({ ...cfg, discount_percent: isNaN(n) ? 0 : n });
}}
placeholder="10"
placeholderTextColor={colors.textMuted}
/>
<Text style={{ fontSize: 12, color: colors.textMuted }}>%</Text>
</View>
<PromotionProductPicker
catConfig={cfg}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
</>
)}
</View>
)}
</View>
);
})}
</View>
)}
</View>
{/* Récapitulatif */}
{promotions.length > 0 && (
<View style={{ backgroundColor: PROMO_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: PROMO_ACCENT, padding: spacing.m, gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "700", color: PROMO_ACCENT }}>Récapitulatif</Text>
{promotions.map((cfg, idx) => (
<Text key={`${cfg.category}-${idx}`} style={{ fontSize: 12, color: colors.textSecondary }}>
{cfg.category} -{cfg.discount_percent}% sur {cfg.all_products
? `tous les produits · qté ${cfg.quantity > 0 ? cfg.quantity : 1}`
: `${cfg.products.length} produit(s) : ${cfg.products.map((pq) => `#${pq.product_id}×${pq.quantity}`).join(", ")}`}
</Text>
))}
</View>
)}
</View>
)}
</AccordionSection>
);
}
// ──────────────────────────────────────────────────────────────
// Offres "achetez X, Y offert" — quantité supplémentaire du même
// produit livrée gratuitement dès qu'un seuil d'achat est atteint,
// indépendant des points et des promotions (cumulable avec elles).
// Plusieurs seuils peuvent coexister sur un même produit (ex: 10g→+1g,
// 20g→+3g) : le seuil le plus élevé atteint par la commande est retenu.
// ──────────────────────────────────────────────────────────────
const FREEGIFT_ACCENT = "#f59e0b";
function FreeGiftTierListEditor({
tiers,
onChange,
colors,
s,
}: {
tiers: FreeGiftTier[];
onChange: (tiers: FreeGiftTier[]) => void;
colors: any;
s: any;
}) {
const updateTier = (idx: number, patch: Partial<FreeGiftTier>) => {
onChange(tiers.map((t, i) => (i === idx ? { ...t, ...patch } : t)));
};
const removeTier = (idx: number) => {
onChange(tiers.filter((_, i) => i !== idx));
};
const addTier = () => {
onChange([...tiers, { buy_quantity: 0, free_quantity: 0 }]);
};
return (
<View style={{ gap: spacing.xs }}>
{tiers.map((t, idx) => (
<View key={idx} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 11, color: colors.textMuted }}>Acheté :</Text>
<TextInput
style={[s.thresholdInput, { width: 50, fontSize: 12 }]}
keyboardType="decimal-pad"
value={t.buy_quantity > 0 ? String(t.buy_quantity) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
updateTier(idx, { buy_quantity: isNaN(n) ? 0 : n });
}}
placeholder="10"
placeholderTextColor={colors.textMuted}
/>
<Ionicons name="arrow-forward" size={12} color={colors.textMuted} />
<Text style={{ fontSize: 11, color: colors.textMuted }}>Offert :</Text>
<TextInput
style={[s.thresholdInput, { width: 50, fontSize: 12 }]}
keyboardType="decimal-pad"
value={t.free_quantity > 0 ? String(t.free_quantity) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
updateTier(idx, { free_quantity: isNaN(n) ? 0 : n });
}}
placeholder="1"
placeholderTextColor={colors.textMuted}
/>
<TouchableOpacity onPress={() => removeTier(idx)} hitSlop={8}>
<Ionicons name="trash-outline" size={15} color={colors.danger ?? "#ef4444"} />
</TouchableOpacity>
</View>
))}
<TouchableOpacity
onPress={addTier}
style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start", marginTop: 2 }}
>
<Ionicons name="add-circle-outline" size={14} color={FREEGIFT_ACCENT} />
<Text style={{ fontSize: 12, color: FREEGIFT_ACCENT, fontWeight: "600" }}>Ajouter un seuil</Text>
</TouchableOpacity>
</View>
);
}
function FreeGiftProductPicker({
catConfig,
products,
onChange,
colors,
s,
}: {
catConfig: CategoryFreeGiftConfig;
products: Product[];
onChange: (cfg: CategoryFreeGiftConfig) => 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;
}
onChange({
...catConfig,
products: [...catConfig.products, { product_id: id, tiers: [{ buy_quantity: 0, free_quantity: 0 }] }],
all_products: false,
});
};
const updateProductTiers = (id: number, tiers: FreeGiftTier[]) => {
onChange({
...catConfig,
products: catConfig.products.map((pq) => (pq.product_id === id ? { ...pq, tiers } : 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 ? FREEGIFT_ACCENT : colors.border,
backgroundColor: catConfig.all_products ? FREEGIFT_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="checkmark-done-outline" size={13} color={catConfig.all_products ? FREEGIFT_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: catConfig.all_products ? "700" : "400", color: catConfig.all_products ? FREEGIFT_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 ? FREEGIFT_ACCENT : colors.border,
backgroundColor: !catConfig.all_products ? FREEGIFT_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="list-outline" size={13} color={!catConfig.all_products ? FREEGIFT_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: !catConfig.all_products ? "700" : "400", color: !catConfig.all_products ? FREEGIFT_ACCENT : colors.textMuted }}>
Sélection
</Text>
</TouchableOpacity>
</View>
{/* Mode "Tous" : seuils uniformes pour tous les produits de la catégorie */}
{catConfig.all_products && (
<View>
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic", marginBottom: 4 }}>
Les quantités achetées doivent correspondre à des paliers de prix existants
</Text>
<FreeGiftTierListEditor
tiers={catConfig.tiers}
onChange={(tiers) => onChange({ ...catConfig, tiers })}
colors={colors}
s={s}
/>
</View>
)}
{/* Mode "Sélection" : chaque produit choisi a ses propres seuils */}
{!catConfig.all_products && (
<View style={{ gap: spacing.xs }}>
{catProducts.length === 0 ? (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
Aucun produit dans cette catégorie
</Text>
) : (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{catProducts.map((p) => {
const sel = catConfig.products.some((pq) => pq.product_id === p.id);
return (
<TouchableOpacity
key={p.id}
onPress={() => toggleProduct(p.id)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: sel ? FREEGIFT_ACCENT : colors.border,
backgroundColor: sel ? FREEGIFT_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{sel && <Ionicons name="checkmark" size={11} color={FREEGIFT_ACCENT} />}
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? FREEGIFT_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);
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>
<FreeGiftTierListEditor
tiers={pq.tiers}
onChange={(tiers) => updateProductTiers(pq.product_id, tiers)}
colors={colors}
s={s}
/>
</View>
);
})}
</View>
)}
</View>
)}
</View>
);
}
function FreeGiftsSection({
enabled,
freeGifts,
allCategories,
productsByCategory,
onToggle,
onChangeFreeGifts,
colors,
s,
}: {
enabled: boolean;
freeGifts: CategoryFreeGiftConfig[];
allCategories: Category[];
productsByCategory: Record<string, Product[]>;
onToggle: (v: boolean) => void;
onChangeFreeGifts: (freeGifts: CategoryFreeGiftConfig[]) => void;
colors: any;
s: any;
}) {
const getCatConfig = (catName: string): CategoryFreeGiftConfig =>
freeGifts.find((g) => g.category === catName) ??
{ category: catName, all_products: true, tiers: [], products: [] };
const isCatSelected = (catName: string) => freeGifts.some((g) => g.category === catName);
const toggleCategory = (catName: string) => {
if (isCatSelected(catName)) {
onChangeFreeGifts(freeGifts.filter((g) => g.category !== catName));
} else {
onChangeFreeGifts([...freeGifts, { category: catName, all_products: true, tiers: [], products: [] }]);
}
};
const updateCatConfig = (cfg: CategoryFreeGiftConfig) => {
onChangeFreeGifts(freeGifts.map((g) => (g.category === cfg.category ? cfg : g)));
};
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 countTiers = (cfg: CategoryFreeGiftConfig) =>
cfg.all_products ? cfg.tiers.length : cfg.products.reduce((sum, pq) => sum + pq.tiers.length, 0);
const badge = (
<View style={{
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
backgroundColor: enabled ? FREEGIFT_ACCENT + "25" : colors.border + "40",
borderWidth: 1, borderColor: enabled ? FREEGIFT_ACCENT : colors.border,
}}>
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? FREEGIFT_ACCENT : colors.textMuted }}>
{enabled ? "Activées" : "Désactivées"}
</Text>
</View>
);
return (
<AccordionSection title="Offres quantité offerte" badge={badge} colors={colors} s={s}>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Offres activées</Text>
<Text style={s.rowDesc}>
Quantité supplémentaire du même produit livrée gratuitement dès qu'un seuil d'achat est atteint (ex: 10g achetés 1g offert) indépendant des points et des promotions, cumulable avec elles.
</Text>
</View>
<Switch
value={enabled}
onValueChange={onToggle}
trackColor={{ false: colors.border, true: FREEGIFT_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 concernées</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
Sélectionnez une catégorie, puis tous les produits ou une sélection, avec un ou plusieurs seuils achat/offert par produit.
</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 || FREEGIFT_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 && countTiers(cfg) > 0 ? ` · ${countTiers(cfg)} seuil(s)` : ""}
</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 ? FREEGIFT_ACCENT : colors.border,
backgroundColor: selected ? FREEGIFT_ACCENT + "22" : "transparent",
}}
>
<Ionicons
name={selected ? "checkbox" : "square-outline"}
size={14}
color={selected ? FREEGIFT_ACCENT : colors.textMuted}
/>
<Ionicons name="gift-outline" size={12} color={selected ? FREEGIFT_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: selected ? "700" : "400", color: selected ? FREEGIFT_ACCENT : colors.textMuted }}>
Offre active sur cette catégorie
</Text>
</TouchableOpacity>
{selected && (
<FreeGiftProductPicker
catConfig={cfg}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
)}
</View>
)}
</View>
);
})}
</View>
)}
</View>
{/* Récapitulatif */}
{freeGifts.length > 0 && (
<View style={{ backgroundColor: FREEGIFT_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: FREEGIFT_ACCENT, padding: spacing.m, gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "700", color: FREEGIFT_ACCENT }}>Récapitulatif</Text>
{freeGifts.map((cfg, idx) => (
<Text key={`${cfg.category}-${idx}`} style={{ fontSize: 12, color: colors.textSecondary }}>
{cfg.category} {cfg.all_products
? `tous les produits · ${cfg.tiers.map((t) => `${t.buy_quantity}→+${t.free_quantity}`).join(", ") || "aucun seuil"}`
: `${cfg.products.length} produit(s) : ${cfg.products.map((pq) => `#${pq.product_id}[${pq.tiers.map((t) => `${t.buy_quantity}→+${t.free_quantity}`).join(",")}]`).join(", ")}`}
</Text>
))}
</View>
)}
</View>
)}
</AccordionSection>
);
}
// Palette violette d'origine de l'application (thème par défaut historique)
const ORIGINAL_THEME_COLORS = {
admin_color_primary: "#7c3aed",
@@ -1178,6 +1974,10 @@ export default function SettingsScreen() {
shop_name: "Milieu-Nantais",
contact_telegram: "",
points_reward: null,
promotions_enabled: false,
promotions: [],
free_gifts_enabled: false,
free_gifts: [],
admin_color_primary: "#7c3aed",
admin_color_secondary: "#22d3ee",
admin_color_success: "#4ade80",
@@ -1257,6 +2057,20 @@ export default function SettingsScreen() {
})),
}
: null,
promotions_enabled: s.promotions_enabled ?? false,
promotions: (s.promotions ?? []).map((cfg) => ({
...cfg,
products: cfg.products ?? [],
})),
free_gifts_enabled: s.free_gifts_enabled ?? false,
free_gifts: (s.free_gifts ?? []).map((cfg) => ({
...cfg,
tiers: cfg.tiers ?? [],
products: (cfg.products ?? []).map((pq) => ({
...pq,
tiers: pq.tiers ?? [],
})),
})),
});
}
if (categoriesRes) {
@@ -1872,6 +2686,30 @@ 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}
/>
{/* Offres "achetez X, Y offert" — quantité offerte du même produit */}
<FreeGiftsSection
enabled={settings.free_gifts_enabled ?? false}
freeGifts={settings.free_gifts ?? []}
allCategories={categories}
productsByCategory={productsByCategory}
onToggle={(v) => setSettings((p) => ({ ...p, free_gifts_enabled: v }))}
onChangeFreeGifts={(free_gifts) => setSettings((p) => ({ ...p, free_gifts }))}
colors={colors}
s={s}
/>
{/* Horaires de livraison */}
<DeliveryScheduleSection
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
@@ -1643,6 +1643,20 @@ export default function StatsScreen() {
color={CHART_AMBER}
/>
</View>
<View style={styles.summaryRow}>
<SummaryCard
icon="pricetag-outline"
label="Économisé (promos)"
value={fmtEuro(s?.total_promo_discount ?? 0)}
color={CHART_GREEN}
/>
<SummaryCard
icon="gift-outline"
label="Commandes avec promo"
value={fmtNum(s?.promo_orders_count ?? 0)}
color={CHART_AMBER}
/>
</View>
{/* ── Activité du jour ── */}
{stats?.daily_detail && (
@@ -75,7 +75,7 @@ interface EnrichedDelivery extends DeliveryItem {
clientUsername?: string;
clientNom?: string;
clientPrenom?: string;
items?: Array<{ produit: string; quantite: number; prix: number; unit?: string; is_reward?: boolean }>;
items?: Array<{ produit: string; quantite: number; prix: number; unit?: string; is_reward?: boolean; promo_discount?: number }>;
}
export default function DashboardScreen() {
@@ -707,7 +707,14 @@ export default function DashboardScreen() {
</Text>
</View>
)}
{item.items.map((prod, idx) => (
{item.items.map((prod, idx) => {
const promoDiscount = prod.promo_discount ?? 0;
const hasPromo = !prod.is_reward && promoDiscount > 0;
const originalPrice = (prod.prix ?? 0) + promoDiscount;
const promoPercent = hasPromo && originalPrice > 0
? Math.round((promoDiscount / originalPrice) * 100)
: 0;
return (
<View key={idx} style={styles.itemRow}>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
@@ -718,16 +725,34 @@ export default function DashboardScreen() {
<Text style={{ fontSize: 10, color: "#f59e0b", fontWeight: "700" }}>Récompense</Text>
</View>
)}
{hasPromo && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(34,197,94,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
<Ionicons name="pricetag-outline" size={10} color="#22c55e" />
<Text style={{ fontSize: 10, color: "#22c55e", fontWeight: "700" }}>-{promoPercent}%</Text>
</View>
)}
</View>
<Text style={styles.itemQty}>
Quantité: {prod.quantite}{prod.unit || ""}
</Text>
</View>
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
</Text>
{hasPromo ? (
<View style={{ alignItems: "flex-end" }}>
<Text style={{ fontSize: 12, color: colors.textMuted, textDecorationLine: "line-through" }}>
{originalPrice.toFixed(2)}
</Text>
<Text style={[styles.itemPrice, { color: "#22c55e" }]}>
{(prod.prix ?? 0).toFixed(2)}
</Text>
</View>
) : (
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
</Text>
)}
</View>
))}
);
})}
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total</Text>
<Text style={styles.totalValue}>
@@ -2010,7 +2035,14 @@ export default function DashboardScreen() {
</Text>
</View>
)}
{detailsDelivery.items.map((prod, idx) => (
{detailsDelivery.items.map((prod, idx) => {
const promoDiscount = prod.promo_discount ?? 0;
const hasPromo = !prod.is_reward && promoDiscount > 0;
const originalPrice = (prod.prix ?? 0) + promoDiscount;
const promoPercent = hasPromo && originalPrice > 0
? Math.round((promoDiscount / originalPrice) * 100)
: 0;
return (
<View key={idx} style={styles.detailProductRow}>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
@@ -2021,16 +2053,34 @@ export default function DashboardScreen() {
<Text style={{ fontSize: 11, color: "#f59e0b", fontWeight: "700" }}>Récompense</Text>
</View>
)}
{hasPromo && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(34,197,94,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
<Ionicons name="pricetag-outline" size={11} color="#22c55e" />
<Text style={{ fontSize: 11, color: "#22c55e", fontWeight: "700" }}>-{promoPercent}%</Text>
</View>
)}
</View>
<Text style={styles.detailProductQty}>
Quantité : {prod.quantite}{prod.unit || ""}
</Text>
</View>
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
</Text>
{hasPromo ? (
<View style={{ alignItems: "flex-end" }}>
<Text style={{ fontSize: 12, color: colors.textMuted, textDecorationLine: "line-through" }}>
{originalPrice.toFixed(2)}
</Text>
<Text style={[styles.detailProductPrice, { color: "#22c55e" }]}>
{(prod.prix ?? 0).toFixed(2)}
</Text>
</View>
) : (
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
</Text>
)}
</View>
))}
);
})}
</>
) : (
<Text style={styles.detailEmpty}>Aucun produit</Text>
+61 -1
View File
@@ -796,7 +796,13 @@ export interface Product {
category: string;
unit?: string;
stock: number;
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
prices?: Array<{
quantity: number;
price: number;
active_price?: boolean;
promo_price?: number | null;
promo_percent?: number;
}>;
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
coming_soon?: boolean;
}
@@ -1440,6 +1446,60 @@ export const cancelCommand = async (
}
};
/**
* UPDATE OWN COMMAND ADDRESS - Corriger l'adresse de sa propre commande
* PUT /api/v1/commands/:id/address
*/
export const updateOwnCommandAddress = async (
commandId: number,
deliveryAddress: string,
): Promise<{ success: boolean; message: string }> => {
const token = sessionStorage.getItem("token");
if (!token) {
return {
success: false,
message: "Session invalide",
};
}
try {
const response = await fetch(
`${API_URL}/commands/${commandId}/address`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ delivery_address: deliveryAddress }),
},
);
const data = await safeJson(response);
if (!response.ok) {
return {
success: false,
message: data.error || "Erreur lors de la mise à jour de l'adresse",
};
}
return {
success: true,
message: data.message || "Adresse mise à jour",
};
} catch (error) {
return {
success: false,
message:
error instanceof Error
? error.message
: "Erreur lors de la mise à jour de l'adresse",
};
}
};
/**
* GET MY CANCELLATION HISTORY - Historique des annulations
* GET /api/v1/my-cancellation-history
+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;
@@ -122,6 +122,13 @@
text-align: center;
}
.product-price-strike {
color: var(--text-muted);
text-decoration: line-through;
font-size: 0.75em;
font-weight: 500;
}
.product-stock {
color: var(--text-muted);
font-size: clamp(0.85rem, 2.5vw, 1rem);
+31 -7
View File
@@ -28,7 +28,13 @@ interface ProductCardProps {
image: string;
stock: number;
category: string;
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
prices?: Array<{
quantity: number;
price: number;
active_price?: boolean;
promo_price?: number | null;
promo_percent?: number;
}>;
hasVideo?: boolean;
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
categoryColor?: string;
@@ -63,6 +69,11 @@ function ProductCard({
const isOutOfStock = stock === 0;
const isComingSoon = coming_soon === true;
const normalizedCategory = (category || "autre").toLowerCase().trim();
const firstPromoPrice =
prices?.[0]?.promo_price != null &&
prices[0].promo_price < prices[0].price
? prices[0].promo_price
: null;
const handleDetailsClick = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -171,9 +182,20 @@ function ProductCard({
<div className="product-info">
<h3 className="product-name">{name}</h3>
<p className="product-price">
{price > 0
? `${price.toFixed(2)}`
: "Prix non disponible"}
{price > 0 ? (
firstPromoPrice !== null ? (
<>
<span className="product-price-strike">
{price.toFixed(2)}
</span>{" "}
{firstPromoPrice.toFixed(2)}
</>
) : (
`${price.toFixed(2)}`
)
) : (
"Prix non disponible"
)}
</p>
</div>
</div>
@@ -224,9 +246,11 @@ function ProductCard({
key={priceOption.quantity}
value={priceOption.quantity}
>
{priceOption.quantity}
{unit} - {priceOption.price.toFixed(2)}{" "}
{priceOption.promo_price != null &&
priceOption.promo_price <
priceOption.price
? `${priceOption.quantity}${unit} - ${priceOption.promo_price.toFixed(2)} € (au lieu de ${priceOption.price.toFixed(2)} €, -${priceOption.promo_percent}%)`
: `${priceOption.quantity}${unit} - ${priceOption.price.toFixed(2)}`}
</option>
))}
</select>
+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>
{!hasPromo &&
selectedGrams &&
` pour ${selectedGrams}${product.unit || "g"}`}
</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>
@@ -14,6 +14,7 @@ import {
getOrderETA,
confirmReception,
cancelCommand,
updateOwnCommandAddress,
isUserAuthenticated,
getPublicSettings,
} from "../../api/api";
@@ -287,6 +288,12 @@ function SuiviLivraison() {
useState<CancelCommandResponse | null>(null);
const [poolNames, setPoolNames] = useState<string[]>([]);
const [editingAddressOrder, setEditingAddressOrder] = useState<
number | null
>(null);
const [newAddress, setNewAddress] = useState("");
const [editAddressLoading, setEditAddressLoading] = useState(false);
useEffect(() => {
getPublicSettings().then((s) => setPoolNames(s.pool_names ?? []));
}, []);
@@ -574,6 +581,53 @@ function SuiviLivraison() {
handleCancelOrder(true);
};
const openEditAddressDialog = (orderId: number) => {
if (!isUserAuthenticated()) {
navigate("/login/client", { replace: true });
return;
}
const order = orders.find((o) => o.id === orderId);
setNewAddress(order ? getDeliveryAddress(order) : "");
setEditingAddressOrder(orderId);
};
const closeEditAddressDialog = () => {
setEditingAddressOrder(null);
setNewAddress("");
};
const handleUpdateAddress = async () => {
if (!editingAddressOrder || !newAddress.trim()) return;
try {
setEditAddressLoading(true);
const response = await updateOwnCommandAddress(
editingAddressOrder,
newAddress.trim(),
);
if (response.success) {
showToast("Adresse mise à jour", "success");
closeEditAddressDialog();
loadOrders();
} else {
showToast(
response.message || "Erreur lors de la mise à jour",
"error",
);
}
} catch (error: unknown) {
showToast(
error instanceof Error
? error.message
: "Erreur lors de la mise à jour de l'adresse",
"error",
);
} finally {
setEditAddressLoading(false);
}
};
if (loading && orders.length === 0) {
return (
<>
@@ -1143,6 +1197,27 @@ function SuiviLivraison() {
</div>
) : (
<div className="action-buttons">
{(statusLow ===
"pending" ||
statusLow ===
"assigned") && (
<button
className="btn-secondary"
onClick={() =>
openEditAddressDialog(
order.id,
)
}
>
<FontAwesomeIcon
icon={
faMapMarkerAlt
}
/>{" "}
Modifier
l'adresse
</button>
)}
<button
className="btn-cancel-order"
onClick={() =>
@@ -1263,6 +1338,73 @@ function SuiviLivraison() {
</div>
)}
{/* Dialog de modification d'adresse */}
{editingAddressOrder !== null && (
<div
className="confirm-dialog-overlay"
onClick={closeEditAddressDialog}
>
<div
className="confirm-dialog"
onClick={(e) => e.stopPropagation()}
>
<div className="confirm-dialog-header">
<h3>
<FontAwesomeIcon icon={faMapMarkerAlt} />{" "}
Modifier l'adresse de livraison
</h3>
</div>
<div className="confirm-dialog-body">
<div className="form-group">
<label htmlFor="new-address">
Nouvelle adresse de livraison
</label>
<textarea
id="new-address"
value={newAddress}
onChange={(e) =>
setNewAddress(e.target.value)
}
placeholder="Adresse complète"
rows={3}
/>
</div>
</div>
<div className="confirm-dialog-actions">
<button
className="btn-secondary"
onClick={closeEditAddressDialog}
disabled={editAddressLoading}
>
Retour
</button>
<button
className="btn-confirm"
onClick={handleUpdateAddress}
disabled={
editAddressLoading || !newAddress.trim()
}
>
{editAddressLoading ? (
<>
<FontAwesomeIcon
icon={faClock}
spin
/>{" "}
Enregistrement...
</>
) : (
<>
<FontAwesomeIcon icon={faCheck} />{" "}
Enregistrer
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Dialog d'annulation */}
{showCancelDialog && (
<div
+23
View File
@@ -457,6 +457,29 @@ export const respondToAddressProposal = async (
}
};
export const updateOwnCommandAddress = async (
commandId: number,
deliveryAddress: string,
): Promise<{ success: boolean; message: string }> => {
try {
const { data } = await apiClient.put(
`${V1}/commands/${commandId}/address`,
{ delivery_address: deliveryAddress },
);
return {
success: true,
message: data.message || "Adresse mise à jour",
};
} catch (error: any) {
return {
success: false,
message:
error.response?.data?.error ||
"Erreur lors de la mise à jour de l'adresse",
};
}
};
export const getOrderTracking = async (
commandId: number,
): Promise<TrackingResponse> => {
+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;
+82 -13
View File
@@ -47,6 +47,8 @@ interface ProductCardProps {
quantity: number;
price: number;
active_price?: boolean;
promo_price?: number | null;
promo_percent?: number;
}>;
media?: Array<{ url: string; type: string }>;
};
@@ -67,6 +69,11 @@ export default function ProductCard({
const activePrices =
product.prices?.filter((p) => p.active_price !== false) ?? [];
const firstPrice = activePrices[0]?.price ?? null;
const firstPromoPrice =
activePrices[0]?.promo_price != null &&
activePrices[0].promo_price < activePrices[0].price
? activePrices[0].promo_price
: null;
const imageMedia = product.media?.find((m) => m.type === "image");
const videoMedia = product.media?.find((m) => m.type === "video");
@@ -198,11 +205,33 @@ export default function ProductCard({
>
{product.name}
</Text>
<Text style={[styles.price, { color: colors.success }]}>
{firstPrice !== null
? `${firstPrice.toFixed(2)}`
: "Prix non disponible"}
</Text>
{firstPrice !== null ? (
firstPromoPrice !== null ? (
<View style={styles.priceRow}>
<Text
style={[
styles.priceStrike,
{ color: colors.textMuted },
]}
>
{firstPrice.toFixed(2)}
</Text>
<Text
style={[styles.price, { color: colors.success }]}
>
{firstPromoPrice.toFixed(2)}
</Text>
</View>
) : (
<Text style={[styles.price, { color: colors.success }]}>
{firstPrice.toFixed(2)}
</Text>
)
) : (
<Text style={[styles.price, { color: colors.success }]}>
Prix non disponible
</Text>
)}
</View>
<View
@@ -317,14 +346,39 @@ export default function ProductCard({
{p.quantity}
{product.unit || "g"}
</Text>
<Text
style={[
styles.pickerOptionPrice,
{ color: catColor },
]}
>
{p.price.toFixed(2)}
</Text>
{p.promo_price != null &&
p.promo_price < p.price ? (
<View style={styles.pickerPriceRow}>
<Text
style={[
styles.priceStrike,
{ color: colors.textMuted },
]}
>
{p.price.toFixed(2)}
</Text>
<Text
style={[
styles.pickerOptionPrice,
{ color: catColor },
]}
>
{p.promo_price.toFixed(2)}
{p.promo_percent
? ` (-${p.promo_percent}%)`
: ""}
</Text>
</View>
) : (
<Text
style={[
styles.pickerOptionPrice,
{ color: catColor },
]}
>
{p.price.toFixed(2)}
</Text>
)}
</View>
<Ionicons
name="add-circle"
@@ -515,6 +569,21 @@ const styles = StyleSheet.create({
fontWeight: fontWeight.bold,
textAlign: "center",
},
priceRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
},
pickerPriceRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
priceStrike: {
fontSize: fontSize.md,
textDecorationLine: "line-through",
},
quickAddSection: { padding: spacing.m, borderTopWidth: 1 },
quickAddBtn: {
width: "100%",
@@ -17,6 +17,7 @@ import {
confirmReception,
cancelCommand,
respondToAddressProposal,
updateOwnCommandAddress,
formatOrderDate,
formatPrice,
calculateOrderTotal,
@@ -74,6 +75,11 @@ export default function OrderTrackingScreen() {
useState<CancelCommandResponse | null>(null);
const [penaltyOrderId, setPenaltyOrderId] = useState<number | null>(null);
const [penaltiesEnabled, setPenaltiesEnabled] = useState(false);
const [editingAddressId, setEditingAddressId] = useState<number | null>(
null,
);
const [newAddress, setNewAddress] = useState("");
const [editAddressLoading, setEditAddressLoading] = useState(false);
const [toastMsg, setToastMsg] = useState("");
const [toastType, setToastType] = useState<
"success" | "error" | "warning" | "info"
@@ -187,6 +193,29 @@ export default function OrderTrackingScreen() {
}
};
const handleUpdateAddress = async (orderId: number) => {
if (!newAddress.trim()) return;
setEditAddressLoading(true);
try {
const res = await updateOwnCommandAddress(
orderId,
newAddress.trim(),
);
if (res.success) {
showToast("Adresse mise à jour", "success");
setEditingAddressId(null);
setNewAddress("");
fetchOrders();
} else {
showToast(res.message || "Erreur", "error");
}
} catch {
showToast("Erreur lors de la mise à jour de l'adresse", "error");
} finally {
setEditAddressLoading(false);
}
};
const styles = useMemo(
() =>
StyleSheet.create({
@@ -407,6 +436,10 @@ export default function OrderTrackingScreen() {
"assigned",
"en_route",
].includes(order.status);
const canEditAddress = [
"pending",
"assigned",
].includes(order.status);
return (
<TouchableOpacity
@@ -636,6 +669,23 @@ export default function OrderTrackingScreen() {
size="sm"
/>
)}
{canEditAddress && (
<Button
title="Modifier l'adresse"
onPress={() => {
setNewAddress(
order.delivery_address ||
order.adresse ||
"",
);
setEditingAddressId(
order.id,
);
}}
variant="outline"
size="sm"
/>
)}
{canCancel && (
<Button
title="Annuler"
@@ -689,6 +739,52 @@ export default function OrderTrackingScreen() {
</View>
</Modal>
<Modal
visible={editingAddressId !== null}
onClose={() => {
setEditingAddressId(null);
setNewAddress("");
}}
title="Modifier l'adresse de livraison"
icon="location-outline"
iconColor={colors.accent}
>
<View style={styles.modalBody}>
<Text style={styles.modalText}>
Nouvelle adresse de livraison :
</Text>
<RNTextInput
style={styles.cancelInput}
placeholder="Adresse complète"
placeholderTextColor={colors.textMuted}
value={newAddress}
onChangeText={setNewAddress}
multiline
/>
<View style={styles.modalActions}>
<Button
title="Retour"
onPress={() => {
setEditingAddressId(null);
setNewAddress("");
}}
variant="outline"
size="md"
/>
<Button
title="Enregistrer"
onPress={() =>
editingAddressId &&
handleUpdateAddress(editingAddressId)
}
loading={editAddressLoading}
variant="success"
size="md"
/>
</View>
</View>
</Modal>
<Modal
visible={cancellingId !== null}
onClose={() => {
@@ -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,44 @@ 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)}
{!hasPromo &&
selectedGrams &&
` pour ${selectedGrams}${product.unit || "g"}`}
</Text>
</View>
);
})()}
<View style={styles.descriptionCard}>
<Text style={styles.descriptionTitle}>Description</Text>
<Text style={styles.descriptionText}>
@@ -719,16 +753,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