From f2f537a194a839784b178a91d806cac888745840 Mon Sep 17 00:00:00 2001 From: Xor290 Date: Sat, 12 Sep 2026 17:08:19 +0200 Subject: [PATCH] chore: build --- backend/gestion/db/db_basket.go | 18 +- backend/gestion/db/db_free_gifts.go | 59 +++ backend/gestion/db/db_settings.go | 30 ++ backend/gestion/models/settings.go | 35 ++ backend/gestion/tests/free_gifts_test.go | 212 +++++++++ frontend-admin/src/api/api_admin.ts | 19 + .../src/screens/admin/SettingsScreen.tsx | 424 +++++++++++++++++- frontend-prep/src/api/api.ts | 8 +- .../src/pages/User/ProductDetail.tsx | 8 +- .../screens/client/ProductDetailScreen.tsx | 9 +- 10 files changed, 806 insertions(+), 16 deletions(-) create mode 100644 backend/gestion/db/db_free_gifts.go create mode 100644 backend/gestion/tests/free_gifts_test.go diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go index 30b7c65d..96680a73 100644 --- a/backend/gestion/db/db_basket.go +++ b/backend/gestion/db/db_basket.go @@ -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 { return fmt.Errorf("erreur lecture stock: %w", err) } - if productInfo.Stock < quantity { - return fmt.Errorf("stock insuffisant") - } var priceResult struct { Price float64 `gorm:"column:price"` @@ -158,6 +155,17 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64) priceResult.Price = discounted } + // Offre "achetez X, Y offert" : le client reçoit une quantité + // supplémentaire du même produit, gratuite, sans changer le prix déjà + // calculé sur la quantité demandée — la quantité livrée/décomptée du + // stock est donc supérieure à la quantité facturée. + freeQuantity := d.ResolveFreeGiftQuantity(productID, productInfo.Category, quantity) + deliveredQuantity := quantity + freeQuantity + + if productInfo.Stock < deliveredQuantity { + return fmt.Errorf("stock insuffisant") + } + var existing struct { ID int `gorm:"column:id"` Quantity float64 `gorm:"column:quantity"` @@ -171,14 +179,14 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64) return tx.Raw(` UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`, - existing.Quantity+quantity, existing.Price+priceResult.Price, + existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price, existing.ID).Scan(&basket).Error } return tx.Raw(` INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at) VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP) RETURNING id, username, product_id, quantity, price, is_reward, created_at`, - username, productID, quantity, priceResult.Price).Scan(&basket).Error + username, productID, deliveredQuantity, priceResult.Price).Scan(&basket).Error }) if err != nil { return nil, err diff --git a/backend/gestion/db/db_free_gifts.go b/backend/gestion/db/db_free_gifts.go new file mode 100644 index 00000000..b2eb17eb --- /dev/null +++ b/backend/gestion/db/db_free_gifts.go @@ -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) +} diff --git a/backend/gestion/db/db_settings.go b/backend/gestion/db/db_settings.go index a476a9ab..e2d62e0b 100644 --- a/backend/gestion/db/db_settings.go +++ b/backend/gestion/db/db_settings.go @@ -149,6 +149,13 @@ func (d *Database) GetSettings() (models.AppSettings, error) { if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil { settings.Promotions = promotions } + case "free_gifts_enabled": + settings.FreeGiftsEnabled = row.Value == "true" + case "free_gifts": + var freeGifts []models.CategoryFreeGiftConfig + if err := json.Unmarshal([]byte(row.Value), &freeGifts); err == nil { + settings.FreeGifts = freeGifts + } case "referral_enabled": settings.ReferralEnabled = row.Value == "true" case "referral_amount": @@ -283,6 +290,27 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { return fmt.Errorf("erreur sérialisation promotions: %w", err) } + if s.FreeGifts == nil { + s.FreeGifts = []models.CategoryFreeGiftConfig{} + } + for i := range s.FreeGifts { + if s.FreeGifts[i].Tiers == nil { + s.FreeGifts[i].Tiers = []models.FreeGiftTier{} + } + if s.FreeGifts[i].Products == nil { + s.FreeGifts[i].Products = []models.FreeGiftProductQuantity{} + } + for j := range s.FreeGifts[i].Products { + if s.FreeGifts[i].Products[j].Tiers == nil { + s.FreeGifts[i].Products[j].Tiers = []models.FreeGiftTier{} + } + } + } + freeGiftsJSON, err := json.Marshal(s.FreeGifts) + if err != nil { + return fmt.Errorf("erreur sérialisation free_gifts: %w", err) + } + if s.NowPaymentsCurrencies == nil { s.NowPaymentsCurrencies = []string{} } @@ -324,6 +352,8 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { {"points_reward", string(rewardJSON)}, {"promotions_enabled", boolStr(s.PromotionsEnabled)}, {"promotions", string(promotionsJSON)}, + {"free_gifts_enabled", boolStr(s.FreeGiftsEnabled)}, + {"free_gifts", string(freeGiftsJSON)}, {"referral_enabled", boolStr(s.ReferralEnabled)}, {"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)}, {"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)}, diff --git a/backend/gestion/models/settings.go b/backend/gestion/models/settings.go index f2330fc3..c43b50eb 100644 --- a/backend/gestion/models/settings.go +++ b/backend/gestion/models/settings.go @@ -86,6 +86,39 @@ type CategoryPromotionConfig struct { Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false } +// FreeGiftTier définit un seuil d'achat et la quantité offerte associée, du +// même produit — plusieurs seuils peuvent coexister pour un même produit +// (ex: 10g achetés → 1g offert, 20g achetés → 3g offerts) ; le seuil le plus +// élevé atteint par la quantité commandée est retenu (voir ResolveFreeGift). +type FreeGiftTier struct { + BuyQuantity float64 `json:"buy_quantity"` // quantité à acheter pour déclencher l'offre + FreeQuantity float64 `json:"free_quantity"` // quantité offerte du même produit +} + +// FreeGiftProductQuantity associe un produit à ses propres seuils +// d'achat/offre, pour le cas où une catégorie n'est pas configurée en "tous +// les produits" — même logique que PromotionProductQuantity mais pour les +// offres quantité achetée/offerte. +type FreeGiftProductQuantity struct { + ProductID int `json:"product_id"` + Tiers []FreeGiftTier `json:"tiers"` +} + +// CategoryFreeGiftConfig définit une offre "achetez X, Y offert" (du même +// produit) appliquée automatiquement dès que la quantité ajoutée au panier +// atteint un seuil configuré — indépendant des points de fidélité et des +// promotions (cumulable avec elles). +// +// Si AllProducts = true, Tiers s'applique uniformément à tous les produits de +// la catégorie. Si AllProducts = false, chaque produit sélectionné dans +// Products a ses propres seuils (Tiers au niveau catégorie est alors ignoré). +type CategoryFreeGiftConfig struct { + Category string `json:"category"` // nom de la catégorie + AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie + Tiers []FreeGiftTier `json:"tiers"` // seuils uniformes si AllProducts = true + Products []FreeGiftProductQuantity `json:"products"` // produits + seuils individuels si AllProducts = false +} + // DaySchedule représente les horaires de livraison pour un jour de la semaine type DaySchedule struct { Enabled bool `json:"enabled"` @@ -141,6 +174,8 @@ type AppSettings struct { PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie + FreeGiftsEnabled bool `json:"free_gifts_enabled"` // activer/désactiver les offres "achetez X, Y offert" + FreeGifts []CategoryFreeGiftConfig `json:"free_gifts"` // offres quantité achetée/offerte par catégorie ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto diff --git a/backend/gestion/tests/free_gifts_test.go b/backend/gestion/tests/free_gifts_test.go new file mode 100644 index 00000000..52e62af6 --- /dev/null +++ b/backend/gestion/tests/free_gifts_test.go @@ -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") + } +} diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index 7cfb5650..96e785cf 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -1139,6 +1139,23 @@ export interface CategoryPromotionConfig { products: PromotionProductQuantity[]; // produits + quantité individuelle si all_products = false } +export interface FreeGiftTier { + buy_quantity: number; // quantité à acheter pour déclencher l'offre + free_quantity: number; // quantité offerte du même produit +} + +export interface FreeGiftProductQuantity { + product_id: number; + tiers: FreeGiftTier[]; // seuils propres à ce produit +} + +export interface CategoryFreeGiftConfig { + category: string; + all_products: boolean; + tiers: FreeGiftTier[]; // seuils uniformes si all_products = true + products: FreeGiftProductQuantity[]; // produits + seuils individuels si all_products = false +} + export interface PointsPool { key: string; name: string; @@ -1240,6 +1257,8 @@ export interface AppSettings { points_reward?: PointsReward | null; promotions_enabled: boolean; promotions: CategoryPromotionConfig[]; + free_gifts_enabled: boolean; + free_gifts: CategoryFreeGiftConfig[]; referral_enabled: boolean; delivery_schedule: DeliverySchedule; postal_zones: PostalZone[]; diff --git a/frontend-admin/src/screens/admin/SettingsScreen.tsx b/frontend-admin/src/screens/admin/SettingsScreen.tsx index 112a8f31..29ff9ab2 100644 --- a/frontend-admin/src/screens/admin/SettingsScreen.tsx +++ b/frontend-admin/src/screens/admin/SettingsScreen.tsx @@ -17,7 +17,7 @@ import { Ionicons } from "@expo/vector-icons"; import { spacing, fontSize, borderRadius } from "../../theme"; import { useTheme } from "../../context/ThemeContext"; import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin"; -import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, 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 AlertModal from "../../components/ui/AlertModal"; 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) => { + 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 ( + + {tiers.map((t, idx) => ( + + Acheté : + 0 ? String(t.buy_quantity) : ""} + onChangeText={(v) => { + const n = parseFloat(v); + updateTier(idx, { buy_quantity: isNaN(n) ? 0 : n }); + }} + placeholder="10" + placeholderTextColor={colors.textMuted} + /> + + Offert : + 0 ? String(t.free_quantity) : ""} + onChangeText={(v) => { + const n = parseFloat(v); + updateTier(idx, { free_quantity: isNaN(n) ? 0 : n }); + }} + placeholder="1" + placeholderTextColor={colors.textMuted} + /> + removeTier(idx)} hitSlop={8}> + + + + ))} + + + Ajouter un seuil + + + ); +} + +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 ( + + {/* Toggle tous / sélection */} + + onChange({ ...catConfig, all_products: true, products: [] })} + style={{ + flexDirection: "row", alignItems: "center", gap: spacing.xs, + paddingHorizontal: spacing.m, paddingVertical: spacing.xs, + borderRadius: borderRadius.full, borderWidth: 1.5, + borderColor: catConfig.all_products ? FREEGIFT_ACCENT : colors.border, + backgroundColor: catConfig.all_products ? FREEGIFT_ACCENT + "22" : "transparent", + }} + > + + + Tous ({catProducts.length}) + + + onChange({ ...catConfig, all_products: false })} + style={{ + flexDirection: "row", alignItems: "center", gap: spacing.xs, + paddingHorizontal: spacing.m, paddingVertical: spacing.xs, + borderRadius: borderRadius.full, borderWidth: 1.5, + borderColor: !catConfig.all_products ? FREEGIFT_ACCENT : colors.border, + backgroundColor: !catConfig.all_products ? FREEGIFT_ACCENT + "22" : "transparent", + }} + > + + + Sélection + + + + + {/* Mode "Tous" : seuils uniformes pour tous les produits de la catégorie */} + {catConfig.all_products && ( + + + Les quantités achetées doivent correspondre à des paliers de prix existants + + onChange({ ...catConfig, tiers })} + colors={colors} + s={s} + /> + + )} + + {/* Mode "Sélection" : chaque produit choisi a ses propres seuils */} + {!catConfig.all_products && ( + + {catProducts.length === 0 ? ( + + Aucun produit dans cette catégorie + + ) : ( + + {catProducts.map((p) => { + const sel = catConfig.products.some((pq) => pq.product_id === p.id); + return ( + 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 && } + + {p.name} + + + ); + })} + + )} + + {catConfig.products.length > 0 && ( + + {catConfig.products.map((pq) => { + const prod = catProducts.find((p) => p.id === pq.product_id); + return ( + + + {prod?.name ?? `Produit #${pq.product_id}`} + + updateProductTiers(pq.product_id, tiers)} + colors={colors} + s={s} + /> + + ); + })} + + )} + + )} + + ); +} + +function FreeGiftsSection({ + enabled, + freeGifts, + allCategories, + productsByCategory, + onToggle, + onChangeFreeGifts, + colors, + s, +}: { + enabled: boolean; + freeGifts: CategoryFreeGiftConfig[]; + allCategories: Category[]; + productsByCategory: Record; + 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>(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 = ( + + + {enabled ? "Activées" : "Désactivées"} + + + ); + + return ( + + + + Offres activées + + 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. + + + + + + {enabled && ( + + + Catégories concernées + + Sélectionnez une catégorie, puis tous les produits ou une sélection, avec un ou plusieurs seuils achat/offert par produit. + + {allCategories.length === 0 ? ( + Aucune catégorie disponible + ) : ( + + {allCategories.map((cat) => { + const selected = isCatSelected(cat.name); + const expanded = expandedCats.has(cat.name); + const catColor = cat.color || FREEGIFT_ACCENT; + const cfg = getCatConfig(cat.name); + return ( + + toggleExpanded(cat.name)} + style={{ + flexDirection: "row", alignItems: "center", gap: spacing.xs, + alignSelf: "flex-start", + paddingHorizontal: spacing.m, paddingVertical: spacing.s, + borderRadius: borderRadius.full, borderWidth: 1.5, + borderColor: selected ? catColor : colors.border, + backgroundColor: selected ? catColor + "22" : "transparent", + }} + > + + + {cat.name}{selected && countTiers(cfg) > 0 ? ` · ${countTiers(cfg)} seuil(s)` : ""} + + + + + {expanded && ( + + toggleCategory(cat.name)} + style={{ + flexDirection: "row", alignItems: "center", gap: 4, + alignSelf: "flex-start", + paddingHorizontal: spacing.s, paddingVertical: 4, + borderRadius: borderRadius.sm, borderWidth: 1.5, + borderColor: selected ? FREEGIFT_ACCENT : colors.border, + backgroundColor: selected ? FREEGIFT_ACCENT + "22" : "transparent", + }} + > + + + + Offre active sur cette catégorie + + + + {selected && ( + + )} + + )} + + ); + })} + + )} + + + {/* Récapitulatif */} + {freeGifts.length > 0 && ( + + Récapitulatif + {freeGifts.map((cfg, idx) => ( + + • {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(", ")}`} + + ))} + + )} + + )} + + ); +} + // Palette violette d'origine de l'application (thème par défaut historique) const ORIGINAL_THEME_COLORS = { admin_color_primary: "#7c3aed", @@ -1577,6 +1976,8 @@ export default function SettingsScreen() { points_reward: null, promotions_enabled: false, promotions: [], + free_gifts_enabled: false, + free_gifts: [], admin_color_primary: "#7c3aed", admin_color_secondary: "#22d3ee", admin_color_success: "#4ade80", @@ -1661,6 +2062,15 @@ export default function SettingsScreen() { ...cfg, products: cfg.products ?? [], })), + free_gifts_enabled: s.free_gifts_enabled ?? false, + free_gifts: (s.free_gifts ?? []).map((cfg) => ({ + ...cfg, + tiers: cfg.tiers ?? [], + products: (cfg.products ?? []).map((pq) => ({ + ...pq, + tiers: pq.tiers ?? [], + })), + })), }); } if (categoriesRes) { @@ -2288,6 +2698,18 @@ export default function SettingsScreen() { s={s} /> + {/* Offres "achetez X, Y offert" — quantité offerte du même produit */} + setSettings((p) => ({ ...p, free_gifts_enabled: v }))} + onChangeFreeGifts={(free_gifts) => setSettings((p) => ({ ...p, free_gifts }))} + colors={colors} + s={s} + /> + {/* Horaires de livraison */} ; + prices?: Array<{ + quantity: number; + price: number; + active_price?: boolean; + promo_price?: number | null; + promo_percent?: number; + }>; media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[] coming_soon?: boolean; } diff --git a/frontend-prep/src/pages/User/ProductDetail.tsx b/frontend-prep/src/pages/User/ProductDetail.tsx index b4293df8..5efc5c5b 100644 --- a/frontend-prep/src/pages/User/ProductDetail.tsx +++ b/frontend-prep/src/pages/User/ProductDetail.tsx @@ -329,10 +329,10 @@ function ProductDetail() { )} {selectedPrice.toFixed(2)} € - {" "} - {selectedGrams && - `pour ${selectedGrams}${product.unit || "g"}`} - {hasPromo && ` (-${selectedTier!.promo_percent}%)`} + + {!hasPromo && + selectedGrams && + ` pour ${selectedGrams}${product.unit || "g"}`}

); })()} diff --git a/mobile/src/screens/client/ProductDetailScreen.tsx b/mobile/src/screens/client/ProductDetailScreen.tsx index 1306b46a..ade69927 100644 --- a/mobile/src/screens/client/ProductDetailScreen.tsx +++ b/mobile/src/screens/client/ProductDetailScreen.tsx @@ -621,11 +621,10 @@ export default function ProductDetailScreen() { hasPromo && { color: "#22c55e" }, ]} > - {selectedPrice.toFixed(2)} €{" "} - {selectedGrams && - `pour ${selectedGrams}${product.unit || "g"}`} - {hasPromo && - ` (-${selectedTier!.promo_percent}%)`} + {selectedPrice.toFixed(2)} € + {!hasPromo && + selectedGrams && + ` pour ${selectedGrams}${product.unit || "g"}`} );