chore: build
This commit is contained in:
@@ -137,9 +137,6 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
|||||||
if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil {
|
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 productInfo.Stock < quantity {
|
|
||||||
return fmt.Errorf("stock insuffisant")
|
|
||||||
}
|
|
||||||
|
|
||||||
var priceResult struct {
|
var priceResult struct {
|
||||||
Price float64 `gorm:"column:price"`
|
Price float64 `gorm:"column:price"`
|
||||||
@@ -158,6 +155,17 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
|||||||
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 {
|
var existing struct {
|
||||||
ID int `gorm:"column:id"`
|
ID int `gorm:"column:id"`
|
||||||
Quantity float64 `gorm:"column:quantity"`
|
Quantity float64 `gorm:"column:quantity"`
|
||||||
@@ -171,14 +179,14 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
|||||||
return tx.Raw(`
|
return tx.Raw(`
|
||||||
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
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`,
|
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.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
|
||||||
existing.ID).Scan(&basket).Error
|
existing.ID).Scan(&basket).Error
|
||||||
}
|
}
|
||||||
return tx.Raw(`
|
return tx.Raw(`
|
||||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
|
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
|
||||||
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
|
||||||
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
||||||
username, productID, quantity, priceResult.Price).Scan(&basket).Error
|
username, productID, deliveredQuantity, priceResult.Price).Scan(&basket).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -149,6 +149,13 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
|||||||
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
|
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
|
||||||
settings.Promotions = promotions
|
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":
|
case "referral_enabled":
|
||||||
settings.ReferralEnabled = row.Value == "true"
|
settings.ReferralEnabled = row.Value == "true"
|
||||||
case "referral_amount":
|
case "referral_amount":
|
||||||
@@ -283,6 +290,27 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
|||||||
return fmt.Errorf("erreur sérialisation promotions: %w", err)
|
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 {
|
if s.NowPaymentsCurrencies == nil {
|
||||||
s.NowPaymentsCurrencies = []string{}
|
s.NowPaymentsCurrencies = []string{}
|
||||||
}
|
}
|
||||||
@@ -324,6 +352,8 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
|||||||
{"points_reward", string(rewardJSON)},
|
{"points_reward", string(rewardJSON)},
|
||||||
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
|
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
|
||||||
{"promotions", string(promotionsJSON)},
|
{"promotions", string(promotionsJSON)},
|
||||||
|
{"free_gifts_enabled", boolStr(s.FreeGiftsEnabled)},
|
||||||
|
{"free_gifts", string(freeGiftsJSON)},
|
||||||
{"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)},
|
||||||
|
|||||||
@@ -86,6 +86,39 @@ type CategoryPromotionConfig struct {
|
|||||||
Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
|
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
|
// 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"`
|
||||||
@@ -141,6 +174,8 @@ type AppSettings struct {
|
|||||||
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
||||||
PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions
|
PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions
|
||||||
Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie
|
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
|
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
||||||
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
||||||
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1139,6 +1139,23 @@ export interface CategoryPromotionConfig {
|
|||||||
products: PromotionProductQuantity[]; // produits + quantité individuelle si all_products = false
|
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 {
|
export interface PointsPool {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1240,6 +1257,8 @@ export interface AppSettings {
|
|||||||
points_reward?: PointsReward | null;
|
points_reward?: PointsReward | null;
|
||||||
promotions_enabled: boolean;
|
promotions_enabled: boolean;
|
||||||
promotions: CategoryPromotionConfig[];
|
promotions: CategoryPromotionConfig[];
|
||||||
|
free_gifts_enabled: boolean;
|
||||||
|
free_gifts: CategoryFreeGiftConfig[];
|
||||||
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, CategoryPromotionConfig, 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 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";
|
||||||
@@ -1520,6 +1520,405 @@ function PromotionsSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// 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)
|
// 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",
|
||||||
@@ -1577,6 +1976,8 @@ export default function SettingsScreen() {
|
|||||||
points_reward: null,
|
points_reward: null,
|
||||||
promotions_enabled: false,
|
promotions_enabled: false,
|
||||||
promotions: [],
|
promotions: [],
|
||||||
|
free_gifts_enabled: false,
|
||||||
|
free_gifts: [],
|
||||||
admin_color_primary: "#7c3aed",
|
admin_color_primary: "#7c3aed",
|
||||||
admin_color_secondary: "#22d3ee",
|
admin_color_secondary: "#22d3ee",
|
||||||
admin_color_success: "#4ade80",
|
admin_color_success: "#4ade80",
|
||||||
@@ -1661,6 +2062,15 @@ export default function SettingsScreen() {
|
|||||||
...cfg,
|
...cfg,
|
||||||
products: cfg.products ?? [],
|
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) {
|
if (categoriesRes) {
|
||||||
@@ -2288,6 +2698,18 @@ export default function SettingsScreen() {
|
|||||||
s={s}
|
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 */}
|
{/* Horaires de livraison */}
|
||||||
<DeliveryScheduleSection
|
<DeliveryScheduleSection
|
||||||
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
|
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
|
||||||
|
|||||||
@@ -796,7 +796,13 @@ export interface Product {
|
|||||||
category: string;
|
category: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
stock: number;
|
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[]
|
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
|
||||||
coming_soon?: boolean;
|
coming_soon?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,10 +329,10 @@ function ProductDetail() {
|
|||||||
)}
|
)}
|
||||||
<span style={hasPromo ? { color: "#22c55e" } : undefined}>
|
<span style={hasPromo ? { color: "#22c55e" } : undefined}>
|
||||||
{selectedPrice.toFixed(2)} €
|
{selectedPrice.toFixed(2)} €
|
||||||
</span>{" "}
|
</span>
|
||||||
{selectedGrams &&
|
{!hasPromo &&
|
||||||
`pour ${selectedGrams}${product.unit || "g"}`}
|
selectedGrams &&
|
||||||
{hasPromo && ` (-${selectedTier!.promo_percent}%)`}
|
` pour ${selectedGrams}${product.unit || "g"}`}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -621,11 +621,10 @@ export default function ProductDetailScreen() {
|
|||||||
hasPromo && { color: "#22c55e" },
|
hasPromo && { color: "#22c55e" },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{selectedPrice.toFixed(2)} €{" "}
|
{selectedPrice.toFixed(2)} €
|
||||||
{selectedGrams &&
|
{!hasPromo &&
|
||||||
`pour ${selectedGrams}${product.unit || "g"}`}
|
selectedGrams &&
|
||||||
{hasPromo &&
|
` pour ${selectedGrams}${product.unit || "g"}`}
|
||||||
` (-${selectedTier!.promo_percent}%)`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user