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