diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go index 30b80975..d11f119b 100644 --- a/backend/gestion/db/db_basket.go +++ b/backend/gestion/db/db_basket.go @@ -7,6 +7,25 @@ import ( "gorm.io/gorm" ) +// GetActiveProductPrice retourne le prix catalogue actif pour un produit et +// une quantité donnés (palier le plus proche ≤ quantity, cf. même requête que +// AddToBasket) — utilisé pour calculer le prix effectif d'une récompense +// "half_price_product" (50% de ce prix). +func (d *Database) GetActiveProductPrice(productID int, quantity float64) (float64, error) { + var result struct { + Price float64 `gorm:"column:price"` + } + err := d.GDB.Raw(` + SELECT price FROM product_prices + WHERE product_id = ? AND quantity <= ? AND active_price = true + ORDER BY quantity DESC LIMIT 1`, + productID, quantity).Scan(&result).Error + if err != nil || result.Price == 0 { + return 0, fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity) + } + return result.Price, nil +} + func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) { var result struct { Price float64 `gorm:"column:price"` @@ -42,7 +61,9 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err return baskets, nil } -// AddRewardsToBasket ajoute plusieurs produits récompense au panier (prix = 0, is_reward = true). +// AddRewardsToBasket ajoute plusieurs produits récompense au panier (is_reward = true), +// au prix fourni par l'appelant dans chaque RewardItem.Price (0 pour un produit offert, +// ou le prix effectif déjà calculé pour une remise — voir handlers/points.go). // Supprime les anciens items récompense avant d'insérer les nouveaux. // Pas de vérification de stock — les récompenses sont gérées par l'admin. func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) { @@ -78,9 +99,9 @@ func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardIte var basket models.Panier if err := tx.Raw(` INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at) - VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, true, ?, CURRENT_TIMESTAMP) RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`, - username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil { + username, item.ProductID, item.Quantity, item.Price, poolKey).Scan(&basket).Error; err != nil { return nil, err } baskets = append(baskets, basket) diff --git a/backend/gestion/handlers/deleviry.go b/backend/gestion/handlers/deleviry.go index cc96d336..0e36d91c 100644 --- a/backend/gestion/handlers/deleviry.go +++ b/backend/gestion/handlers/deleviry.go @@ -155,9 +155,10 @@ func GetDeliveryDetails(c *gin.Context) { itemsSummary := make([]gin.H, len(items)) for i, item := range items { itemsSummary[i] = gin.H{ - "produit": item["produit"], - "quantite": item["quantite"], - "prix": item["prix"], + "produit": item["produit"], + "quantite": item["quantite"], + "prix": item["prix"], + "is_reward": item["is_reward"], } } diff --git a/backend/gestion/handlers/points.go b/backend/gestion/handlers/points.go index d48978f6..a97aa39b 100644 --- a/backend/gestion/handlers/points.go +++ b/backend/gestion/handlers/points.go @@ -1,22 +1,35 @@ package handlers import ( + "fmt" "gestion/db" "gestion/models" "gestion/utils" "log" + "math" "net/http" "strings" "github.com/gin-gonic/gin" ) -// eligibleRewardProductIDs détermine, pour un pool donné, quels product_id de -// reward.RewardItems sont éligibles : sa catégorie (via CategoryConfigs) doit -// faire partie des catégories du pool, soit par whitelist explicite (ProductIDs) -// soit par correspondance de catégorie produit (AllProducts). -func eligibleRewardProductIDs(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]bool { - eligible := make(map[int]bool) +// normalizeRewardCategoryType retombe sur "free_product" pour toute valeur +// vide ou inconnue — rétrocompatibilité avec les configurations enregistrées +// avant l'introduction du type par catégorie (RewardCategoryConfig.Type). +func normalizeRewardCategoryType(t string) string { + if t == "half_price_product" { + return "half_price_product" + } + return "free_product" +} + +// eligibleRewardProducts détermine, pour un pool donné, quels product_id de +// reward.RewardItems sont éligibles et avec quel type de récompense +// ("free_product" | "half_price_product") : sa catégorie (via CategoryConfigs) +// doit faire partie des catégories du pool, soit par whitelist explicite +// (ProductIDs) soit par correspondance de catégorie produit (AllProducts). +func eligibleRewardProducts(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]string { + eligible := make(map[int]string) if reward == nil { return eligible } @@ -24,21 +37,62 @@ func eligibleRewardProductIDs(reward *models.PointsReward, poolCategories map[st if !poolCategories[cfg.Category] { continue } + rewardType := normalizeRewardCategoryType(cfg.Type) if cfg.AllProducts { for pid, cat := range productCategories { if cat == cfg.Category { - eligible[pid] = true + eligible[pid] = rewardType } } } else { for _, pid := range cfg.ProductIDs { - eligible[pid] = true + eligible[pid] = rewardType } } } return eligible } +// categoryConfigTypeForProduct détermine le type de récompense applicable à un +// produit à partir de sa catégorie catalogue, sans filtrer par pool — utilisé +// pour l'aperçu global (rewardMeta) qui n'est pas rattaché à un pool précis. +func categoryConfigTypeForProduct(reward *models.PointsReward, productID int, productCategory string) string { + for _, cfg := range reward.CategoryConfigs { + matches := false + if cfg.AllProducts { + matches = cfg.Category == productCategory + } else { + for _, pid := range cfg.ProductIDs { + if pid == productID { + matches = true + break + } + } + } + if matches { + return normalizeRewardCategoryType(cfg.Type) + } + } + return "free_product" +} + +// effectiveRewardPrice calcule le prix réellement facturé pour un item +// récompense selon le type de sa catégorie : 0€ pour "free_product", 50% du +// prix catalogue actif (palier correspondant à la quantité) pour +// "half_price_product". Erreur si le prix catalogue est introuvable (produit +// désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas +// être proposée/réclamée plutôt que de facturer un montant incorrect. +func effectiveRewardPrice(database *db.Database, item models.RewardItem, rewardType string) (float64, error) { + if rewardType != "half_price_product" { + return 0, nil + } + catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity) + if err != nil { + return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", item.ProductID, err) + } + return math.Round(catalogPrice/2*100) / 100, nil +} + // GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté. // La récompense est globale : son seuil s'applique indépendamment à chaque pool. func GetMyPointsRewards(c *gin.Context) { @@ -71,6 +125,7 @@ func GetMyPointsRewards(c *gin.Context) { type EligibleConfigResponse struct { Category string `json:"category"` + Type string `json:"type"` AllProducts bool `json:"all_products"` ProductIDs []int `json:"product_ids"` ProductNames []string `json:"product_names"` @@ -81,6 +136,7 @@ func GetMyPointsRewards(c *gin.Context) { ProductName string `json:"product_name"` Quantity float64 `json:"quantity"` Price float64 `json:"price"` + Type string `json:"type"` } type PoolInfo struct { @@ -141,6 +197,7 @@ func GetMyPointsRewards(c *gin.Context) { } eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{ Category: cfg.Category, + Type: normalizeRewardCategoryType(cfg.Type), AllProducts: cfg.AllProducts, ProductIDs: cfg.ProductIDs, ProductNames: names, @@ -148,18 +205,25 @@ func GetMyPointsRewards(c *gin.Context) { } } - eligibleProductIDs := eligibleRewardProductIDs(reward, poolCats, productCategories) + eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories) eligibleRewardItems := make([]RewardItemResponse, 0) if reward != nil { for _, item := range reward.RewardItems { - if !eligibleProductIDs[item.ProductID] { + rewardType, ok := eligibleProducts[item.ProductID] + if !ok { + continue + } + price, err := effectiveRewardPrice(database, item, rewardType) + if err != nil { + log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err) continue } eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{ ProductID: item.ProductID, ProductName: productNames[item.ProductID], Quantity: item.Quantity, - Price: item.Price, + Price: price, + Type: rewardType, }) } } @@ -176,7 +240,9 @@ func GetMyPointsRewards(c *gin.Context) { }) } - // Construire la liste des produits récompense avec leurs noms + // Construire la liste des produits récompense avec leurs noms (aperçu + // global, indépendant d'un pool précis — le type/prix effectif par pool + // est celui exposé dans pools[].eligible_reward_items). var rewardMeta gin.H if reward != nil { rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems)) @@ -184,17 +250,22 @@ func GetMyPointsRewards(c *gin.Context) { if item.ProductID <= 0 { continue } + rewardType := categoryConfigTypeForProduct(reward, item.ProductID, productCategories[item.ProductID]) + price, err := effectiveRewardPrice(database, item, rewardType) + if err != nil { + price = item.Price // fallback indicatif si le prix catalogue est momentanément indisponible + } name := productNames[item.ProductID] rewardItems = append(rewardItems, RewardItemResponse{ ProductID: item.ProductID, ProductName: name, Quantity: item.Quantity, - Price: item.Price, + Price: price, + Type: rewardType, }) } rewardMeta = gin.H{ "threshold": reward.Threshold, - "type": reward.Type, "description": reward.Description, "reward_items": rewardItems, } @@ -273,13 +344,26 @@ func ClaimMyReward(c *gin.Context) { return } - eligibleProductIDs := eligibleRewardProductIDs(reward, poolCategories, productCategories) + eligibleProducts := eligibleRewardProducts(reward, poolCategories, productCategories) + // Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici, + // avant toute écriture — si un item ne peut pas être tarifé (produit sans + // palier de prix actif), la réclamation entière échoue proprement, avant + // même de démarrer la transaction de consommation de points. eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems)) for _, item := range reward.RewardItems { - if eligibleProductIDs[item.ProductID] { - eligibleItems = append(eligibleItems, item) + rewardType, ok := eligibleProducts[item.ProductID] + if !ok { + continue } + price, err := effectiveRewardPrice(database, item, rewardType) + if err != nil { + log.Printf("❌ [CLAIM] %s: %v", username, err) + c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"}) + return + } + item.Price = price + eligibleItems = append(eligibleItems, item) } itemsToAdd := eligibleItems diff --git a/backend/gestion/models/product.go b/backend/gestion/models/product.go index 83b8edcc..9225c778 100644 --- a/backend/gestion/models/product.go +++ b/backend/gestion/models/product.go @@ -19,12 +19,19 @@ type Product struct { func (Product) TableName() string { return "products" } type ProductPrice struct { - ID int `json:"id" gorm:"primaryKey;autoIncrement"` - ProductID int `json:"product_id" gorm:"column:product_id;index"` - Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"` - Price float64 `json:"price" gorm:"column:price" binding:"required"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - ActivePrice bool `json:"active_price" gorm:"column:active_price;default:true"` + ID int `json:"id" gorm:"primaryKey;autoIncrement"` + ProductID int `json:"product_id" gorm:"column:product_id;index"` + Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"` + Price float64 `json:"price" gorm:"column:price" binding:"required"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + // Pas de tag gorm "default:true" ici : GORM omet de l'INSERT tout champ + // dont la valeur Go est la valeur zéro (false) s'il porte un tag + // "default", laissant Postgres appliquer sa propre valeur par défaut + // (TRUE) à la place — un prix explicitement désactivé (false) revenait + // donc toujours actif après un Create(). La colonne a déjà son défaut + // TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et + // seulement source du bug. + ActivePrice bool `json:"active_price" gorm:"column:active_price"` } func (ProductPrice) TableName() string { return "product_prices" } diff --git a/backend/gestion/models/settings.go b/backend/gestion/models/settings.go index a88eeb45..be603ce4 100644 --- a/backend/gestion/models/settings.go +++ b/backend/gestion/models/settings.go @@ -14,9 +14,11 @@ type PointsTier struct { Points int `json:"points"` } -// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense +// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense, +// ainsi que le type de récompense appliqué pour cette catégorie précise. type RewardCategoryConfig struct { Category string `json:"category"` // nom de la catégorie + Type string `json:"type"` // "free_product" (défaut) | "half_price_product" AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false } @@ -28,12 +30,13 @@ type RewardItem struct { Price float64 `json:"price"` // valeur indicative affichée au client } -// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés +// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés. +// Le type de récompense (gratuit ou -50%) n'est plus global : il est défini par catégorie +// dans CategoryConfigs (voir RewardCategoryConfig.Type). type PointsReward struct { Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20) - Type string `json:"type"` // "free_product" | "half_price_product" | "custom" Description string `json:"description"` // description libre affichée au client - CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type par catégorie RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim } @@ -99,8 +102,8 @@ type AppSettings struct { NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"]) DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande - TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison - TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @) + TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison + TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @) TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client diff --git a/backend/gestion/tests/deleviry_details_test.go b/backend/gestion/tests/deleviry_details_test.go new file mode 100644 index 00000000..f79b63fc --- /dev/null +++ b/backend/gestion/tests/deleviry_details_test.go @@ -0,0 +1,81 @@ +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "gestion/handlers" + + "github.com/gin-gonic/gin" +) + +func deliveryDetailsContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/livreur/deliveries/%d", commandID), nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}} + c.Set("database", testDB) + c.Set("username", username) + c.Set("role", "livreur") + return c, rec +} + +// GetDeliveryDetails doit exposer is_reward par item, au même titre que +// GetMyDeliveries (la liste) — sans quoi le modal "détails" côté livreur ne +// peut pas signaler un article récompense (gratuit ou -50%), ni afficher son +// prix effectif correctement. +func TestGetDeliveryDetails_ExposesIsRewardPerItem(t *testing.T) { + cleanupStockTestData(t) + client := newTestClient(t, "delivdetails_client") + livreur := newTestClient(t, "delivdetails_livreur") + paidProductID := newTestProduct(t, "DelivDetailsPaid", 20) + rewardProductID := newTestProduct(t, "DelivDetailsReward", 5) + + cmdID := newTestCommandWithItem(t, client, "en_route", livreur, paidProductID, 1, 10) + insertRewardCommandItem(t, cmdID, rewardProductID, 1, 5, "pool_0") + + c, rec := deliveryDetailsContext(livreur, cmdID) + handlers.GetDeliveryDetails(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + Success bool `json:"success"` + Delivery struct { + Items []struct { + Produit string `json:"produit"` + IsReward bool `json:"is_reward"` + } `json:"items"` + } `json:"delivery"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String()) + } + if !resp.Success { + t.Fatalf("réponse non successful: body=%s", rec.Body.String()) + } + if len(resp.Delivery.Items) != 2 { + t.Fatalf("nombre d'items: got=%d want=2", len(resp.Delivery.Items)) + } + + var sawReward, sawPaid bool + for _, it := range resp.Delivery.Items { + if it.IsReward { + sawReward = true + } else { + sawPaid = true + } + } + if !sawReward { + t.Errorf("l'item récompense doit avoir is_reward=true dans la réponse: %+v", resp.Delivery.Items) + } + if !sawPaid { + t.Errorf("l'item payant doit avoir is_reward=false dans la réponse: %+v", resp.Delivery.Items) + } +} diff --git a/backend/gestion/tests/points_approval_test.go b/backend/gestion/tests/points_approval_test.go index 27d6dc14..2fc46a55 100644 --- a/backend/gestion/tests/points_approval_test.go +++ b/backend/gestion/tests/points_approval_test.go @@ -204,7 +204,7 @@ func TestCalculateAndAddPointsForCommandTx_RewardItemDeductsThresholdFromPoolPoi setPointsPoolsSettings(t, []models.PointsPool{ {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 3}}}, }) - setPointsRewardSettings(t, models.PointsReward{Threshold: 20, Type: "free_product"}) + setPointsRewardSettings(t, models.PointsReward{Threshold: 20}) setClientPoolPoints(t, username, "pool_0", 25) // solde de départ avant cette commande cmdID := newTestCommandWithItem(t, username, "livre", "", paidProductID, 1, 10) diff --git a/backend/gestion/tests/rewards_handler_test.go b/backend/gestion/tests/rewards_handler_test.go index e5494a44..32d3bc52 100644 --- a/backend/gestion/tests/rewards_handler_test.go +++ b/backend/gestion/tests/rewards_handler_test.go @@ -24,9 +24,14 @@ func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.R return c, rec } +// configureRewardSettings applique la récompense donnée, avec pool_0 mappé +// sur la catégorie "test" — nécessaire pour que eligibleRewardProducts +// (qui croise pool.Categories et reward.CategoryConfigs) considère les +// reward_items comme éligibles. func configureRewardSettings(t *testing.T, reward *models.PointsReward) { t.Helper() settings := db.DefaultSettings() + settings.PointsPools[0].Categories = []string{"test"} settings.PointsReward = reward if err := testDB.UpdateSettings(settings); err != nil { t.Fatalf("UpdateSettings: %v", err) @@ -41,10 +46,10 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5) configureRewardSettings(t, &models.PointsReward{ - Threshold: 20, - Type: "free_product", - Description: "Un produit offert", - RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, + Threshold: 20, + Description: "Un produit offert", + CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}}, + RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, }) setClientPoolPoints(t, username, "pool_0", 20) @@ -75,6 +80,43 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test if len(rows) != 1 || rows[0].ProductID != rewardProductID { t.Errorf("le produit récompense doit être dans le panier: %+v", rows) } + if rows[0].Price != 0 { + t.Errorf("catégorie free_product: le prix en panier doit être 0: got=%.2f", rows[0].Price) + } +} + +// Catégorie configurée en "half_price_product" : le produit récompense doit +// être ajouté au panier à 50% du prix catalogue actif (pas 0€, pas le prix +// indicatif RewardItem.Price saisi par l'admin). +func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_http_halfprice") + rewardProductID := newTestProduct(t, "RewardHTTPHalfPrice", 5) + // newTestProduct crée un prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go). + + configureRewardSettings(t, &models.PointsReward{ + Threshold: 20, + Description: "Un produit à moitié prix", + CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "half_price_product", AllProducts: true}}, + RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 999}}, // Price indicatif, doit être ignoré + }) + setClientPoolPoints(t, username, "pool_0", 20) + + body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"}) + c, rec := claimRewardContext(username, body) + handlers.ClaimMyReward(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + rows := basketRewardItems(t, username) + if len(rows) != 1 || rows[0].ProductID != rewardProductID { + t.Fatalf("le produit récompense doit être dans le panier: %+v", rows) + } + if rows[0].Price != 5.0 { + t.Errorf("catégorie half_price_product: prix attendu = 50%% de 10.00€ = 5.00€: got=%.2f", rows[0].Price) + } } func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) { @@ -83,9 +125,9 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) { rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5) configureRewardSettings(t, &models.PointsReward{ - Threshold: 20, - Type: "free_product", - RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, + Threshold: 20, + CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}}, + RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, }) setClientPoolPoints(t, username, "pool_0", 5) @@ -108,9 +150,12 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) username := newTestClient(t, "reward_http_missing_product") configureRewardSettings(t, &models.PointsReward{ - Threshold: 20, - Type: "free_product", - RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant + Threshold: 20, + // ProductIDs explicite (pas AllProducts) : le produit n'existe pas en + // base, donc il n'apparaîtrait jamais dans productCategories et ne + // serait jamais éligible via une correspondance AllProducts. + CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", ProductIDs: []int{999999999}}}, + RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant }) setClientPoolPoints(t, username, "pool_0", 20) diff --git a/backend/gestion/tests/rewards_test.go b/backend/gestion/tests/rewards_test.go index b60b535f..f2425f35 100644 --- a/backend/gestion/tests/rewards_test.go +++ b/backend/gestion/tests/rewards_test.go @@ -206,7 +206,12 @@ func TestClaimPoolRewardAndAddToBasket_RollsBackBothOnInvalidProduct(t *testing. // ── AddRewardsToBasket : flags et remplacement ────────────────────────────── -func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) { +// AddRewardsToBasket ne recalcule plus le prix : elle stocke tel quel le +// RewardItem.Price fourni par l'appelant (0 pour "free_product", prix -50% +// déjà résolu par handlers/points.go pour "half_price_product") — voir +// TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice +// pour le flux complet qui résout ce prix par type de catégorie. +func TestAddRewardsToBasket_SetsRewardFlagsAndStoresGivenPrice(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_basket_flags") productID := newTestProduct(t, "RewardBasketFlags", 20) @@ -231,8 +236,8 @@ func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) { if row.RewardPoolKey != "pool_0" { t.Errorf("reward_pool_key: got=%q want=%q", row.RewardPoolKey, "pool_0") } - if row.Price != 0 { - t.Errorf("prix affiché doit être 0 (gratuit): got=%.2f", row.Price) + if row.Price != 15.0 { + t.Errorf("le prix fourni par l'appelant doit être stocké tel quel: got=%.2f want=15.00", row.Price) } if row.Quantity != 2 { t.Errorf("quantité: got=%.2f want=2", row.Quantity) diff --git a/backend/gestion/tests/stock_coverage_test.go b/backend/gestion/tests/stock_coverage_test.go index 678b2b05..76421889 100644 --- a/backend/gestion/tests/stock_coverage_test.go +++ b/backend/gestion/tests/stock_coverage_test.go @@ -278,6 +278,55 @@ func TestUpdateProduct_UpdatesStockWhenProvided(t *testing.T) { } } +// productPriceActiveFlags relit active_price par palier de quantité pour un +// produit, directement en base. +func productPriceActiveFlags(t *testing.T, productID int) map[float64]bool { + t.Helper() + var rows []struct { + Quantity float64 `gorm:"column:quantity"` + ActivePrice bool `gorm:"column:active_price"` + } + if err := testDB.GDB.Raw( + `SELECT quantity, active_price FROM product_prices WHERE product_id = ?`, productID, + ).Scan(&rows).Error; err != nil { + t.Fatalf("lecture active_price: %v", err) + } + out := make(map[float64]bool, len(rows)) + for _, r := range rows { + out[r.Quantity] = r.ActivePrice + } + return out +} + +// Un prix explicitement désactivé (active_price=false) doit rester désactivé +// après UpdateProduct — piège classique de GORM : un champ bool à sa valeur +// zéro (false) avec un tag gorm "default" est omis de l'INSERT, laissant la +// base appliquer son propre défaut (TRUE) à la place. Voir le commentaire sur +// ProductPrice.ActivePrice dans models/product.go. +func TestUpdateProduct_PersistsInactivePriceFlag(t *testing.T) { + cleanupStockTestData(t) + cat := newTestCategory(t, "CatInactive") + productID := newTestProduct(t, "UPInactive", 5) + + body := []byte(fmt.Sprintf( + `{"name":"UPInactive","category":%q,"description":"d2","unit":"g","stock":10,"prices":[{"quantity":1,"price":5,"active_price":false},{"quantity":5,"price":20,"active_price":true}]}`, + cat, + )) + c, rec := updateProductJSONContext("admin", body, productID) + handlers.UpdateProduct(c) + if rec.Code != http.StatusOK { + t.Fatalf("UpdateProduct valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + + flags := productPriceActiveFlags(t, productID) + if flags[1] != false { + t.Errorf("palier qty=1 doit rester désactivé après UpdateProduct: got active_price=%v", flags[1]) + } + if flags[5] != true { + t.Errorf("palier qty=5 doit rester actif après UpdateProduct: got active_price=%v", flags[5]) + } +} + // ── DeleteProduct ──────────────────────────────────────────────────────── func TestDeleteProduct_RemovesProductWithoutMedia(t *testing.T) { diff --git a/frontend-prep/src/api/api.ts b/frontend-prep/src/api/api.ts index d8ac5ea8..6920a774 100644 --- a/frontend-prep/src/api/api.ts +++ b/frontend-prep/src/api/api.ts @@ -1440,55 +1440,6 @@ export const cancelCommand = async ( } }; -// Le client corrige lui-même l'adresse de sa commande (refusé si déjà en -// livraison ou terminée, cf. UpdateOwnCommandAddress côté backend). -export const updateOwnCommandAddress = async ( - commandId: number, - deliveryAddress: string, -): Promise<{ success: boolean; message: string }> => { - const token = sessionStorage.getItem("token"); - - if (!token) { - return { success: false, message: "Session invalide" }; - } - - try { - const response = await fetch( - `${API_URL}/commands/${commandId}/address`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ delivery_address: deliveryAddress }), - }, - ); - - const data = await safeJson(response); - - if (!response.ok) { - return { - success: false, - message: data.error || "Erreur lors de la mise à jour", - }; - } - - return { - success: true, - message: data.message || "Adresse mise à jour", - }; - } catch (error) { - return { - success: false, - message: - error instanceof Error - ? error.message - : "Erreur lors de la mise à jour de l'adresse", - }; - } -}; - /** * ✅ GET MY CANCELLATION HISTORY - Historique des annulations * GET /api/v1/my-cancellation-history @@ -2217,6 +2168,7 @@ export const unlinkTelegram = async (): Promise => { export type RewardCategoryConfig = { category: string; + type: "free_product" | "half_price_product"; all_products: boolean; product_ids: number[]; product_names: string[]; @@ -2228,6 +2180,7 @@ export type RewardItemConfig = { product_name: string; quantity: number; price: number; + type: "free_product" | "half_price_product"; }; export type PointsPoolInfo = { @@ -2243,7 +2196,6 @@ export type PointsPoolInfo = { export type PointsRewardConfig = { threshold: number; - type: string; description: string; reward_items: RewardItemConfig[]; }; diff --git a/frontend-prep/src/pages/User/Cart.tsx b/frontend-prep/src/pages/User/Cart.tsx index 08e3ec56..3252cf98 100644 --- a/frontend-prep/src/pages/User/Cart.tsx +++ b/frontend-prep/src/pages/User/Cart.tsx @@ -5,6 +5,8 @@ import { useNavigate } from "react-router-dom"; import { isUserAuthenticated, getProductById, getMediaUrl } from "../../api/api"; import type { Product } from "../../api/api"; import { Trash2, ShoppingCart, AlertTriangle, Leaf, X } from "lucide-react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faGift } from "@fortawesome/free-solid-svg-icons"; import "./Cart.css"; interface CartItemWithMedia { @@ -180,15 +182,18 @@ function Cart() {

{item.name_product} {item.is_reward && ( - - 🎁 Récompense + + + Récompense )}

{item.quantity}g

- {item.is_reward ? ( + {item.is_reward && item.price === 0 ? ( Offert + ) : item.is_reward ? ( + {item.price.toFixed(2)} € ) : ( `${item.price.toFixed(2)} €` )} diff --git a/frontend-prep/src/pages/User/ConsultationHistorique.tsx b/frontend-prep/src/pages/User/ConsultationHistorique.tsx index cce6b954..1bcecde1 100644 --- a/frontend-prep/src/pages/User/ConsultationHistorique.tsx +++ b/frontend-prep/src/pages/User/ConsultationHistorique.tsx @@ -210,7 +210,7 @@ function ConsultationHistorique() { if (res.success) { const text = res.product_added && res.product_names?.length - ? `🎁 ${res.product_names.join(", ")} ajouté${res.product_names.length > 1 ? "s" : ""} à votre panier ! Commandez au moins un produit pour en profiter.` + ? `${res.product_names.join(", ")} ajouté${res.product_names.length > 1 ? "s" : ""} à votre panier ! Commandez au moins un produit pour en profiter.` : res.description || "Récompense réclamée !"; setClaimFeedback({ pool: poolKey, type: "success", text }); getMyPointsRewards().then((r) => { @@ -505,6 +505,15 @@ function ConsultationHistorique() {

+ {feedback.type === + "success" && ( + + )} {feedback.text}

)} @@ -747,13 +756,18 @@ function ConsultationHistorique() { item.quantity !== 1 ? `×${item.quantity}` : ""} - {item.price > 0 - ? ` · valeur ${item.price.toFixed(2)} €` + {item.type === + "half_price_product" && + item.price > 0 + ? ` · ${item.price.toFixed(2)} €` : ""} - Offert + {item.type === + "half_price_product" + ? "-50%" + : "Offert"} ); diff --git a/frontend-prep/src/pages/User/ProductDetail.css b/frontend-prep/src/pages/User/ProductDetail.css index 1a08706f..ae11ec30 100644 --- a/frontend-prep/src/pages/User/ProductDetail.css +++ b/frontend-prep/src/pages/User/ProductDetail.css @@ -594,4 +594,55 @@ animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } +} + +/* Modal stock insuffisant */ +.stock-warning-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(6px); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.stock-warning-modal-content { + position: relative; + width: 100%; + max-width: 340px; + padding: 2rem; + text-align: center; + background: #1a1a1a; + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); + color: #fff; +} + +.stock-warning-close-btn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: transparent; + border: none; + color: #aaa; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.stock-warning-ok-btn { + background: #ef4444; + color: #fff; + border: none; + border-radius: 8px; + padding: 0.6rem 1.5rem; + font-weight: 700; + cursor: pointer; } \ No newline at end of file diff --git a/frontend-prep/src/pages/User/ProductDetail.tsx b/frontend-prep/src/pages/User/ProductDetail.tsx index 00396572..b695232a 100644 --- a/frontend-prep/src/pages/User/ProductDetail.tsx +++ b/frontend-prep/src/pages/User/ProductDetail.tsx @@ -1,5 +1,7 @@ import { useParams, useNavigate } from "react-router-dom"; import { useState, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; import { getProductById, getCategories, @@ -25,6 +27,7 @@ function ProductDetail() { // floats const [selectedGrams, setSelectedGrams] = useState(null); const [selectedPrice, setSelectedPrice] = useState(0.0); + const [stockWarning, setStockWarning] = useState<{ wanted: number; available: number } | null>(null); // ✅ TOAST STATE const [toast, setToast] = useState<{ @@ -171,6 +174,11 @@ function ProductDetail() { return; } + if (product.stock > 0 && selectedGrams > product.stock) { + setStockWarning({ wanted: selectedGrams, available: product.stock }); + return; + } + addToCart({ product_id: product.id, name_product: product.name, @@ -363,6 +371,50 @@ function ProductDetail() { + + {/* Modal stock insuffisant */} + {stockWarning && + createPortal( +
setStockWarning(null)} + > +
e.stopPropagation()} + > + +
+ ⚠️ +
+

+ Stock insuffisant +

+

+ Vous avez sélectionné{" "} + {stockWarning.wanted}{product.unit || "g"} mais il ne + reste que{" "} + + {stockWarning.available}{product.unit || "g"} + {" "} + disponible pour {product.name}. +

+ +
+
, + document.body, + )} ); } diff --git a/frontend-prep/src/pages/User/SuiviLivraison.css b/frontend-prep/src/pages/User/SuiviLivraison.css index 00a43039..3f0cfcd5 100644 --- a/frontend-prep/src/pages/User/SuiviLivraison.css +++ b/frontend-prep/src/pages/User/SuiviLivraison.css @@ -489,40 +489,6 @@ font-weight: 600; color: var(--text); } -.address-edit-toggle { - display: inline-flex; - align-items: center; - gap: 0.4rem; - margin-top: 0.4rem; - background: none; - border: none; - color: var(--primary); - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; - padding: 0; -} -.address-edit-toggle:hover { - text-decoration: underline; -} -.address-edit { - margin-top: 0.5rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} -.address-edit-input { - padding: 0.5rem 0.75rem; - border: 1px solid var(--border); - border-radius: 8px; - font-size: 0.85rem; - background: var(--bg-secondary, #fff); - color: var(--text); -} -.address-edit-actions { - display: flex; - gap: 0.5rem; -} .contact { color: var(--text-muted); font-size: 0.85rem; diff --git a/frontend-prep/src/pages/User/SuiviLivraison.tsx b/frontend-prep/src/pages/User/SuiviLivraison.tsx index 59fb8d54..8c46ba28 100644 --- a/frontend-prep/src/pages/User/SuiviLivraison.tsx +++ b/frontend-prep/src/pages/User/SuiviLivraison.tsx @@ -14,7 +14,6 @@ import { getOrderETA, confirmReception, cancelCommand, - updateOwnCommandAddress, isUserAuthenticated, getPublicSettings, } from "../../api/api"; @@ -52,7 +51,6 @@ import { faWind, faChevronUp, faChevronDown, - faPen, } from "@fortawesome/free-solid-svg-icons"; interface OrderWithTracking extends OrderDetail { @@ -284,11 +282,6 @@ function SuiviLivraison() { const [showCancelDialog, setShowCancelDialog] = useState(false); const [orderToCancel, setOrderToCancel] = useState(null); const [cancelReason, setCancelReason] = useState(""); - const [editingAddressOrder, setEditingAddressOrder] = useState< - number | null - >(null); - const [newAddressValue, setNewAddressValue] = useState(""); - const [savingAddress, setSavingAddress] = useState(false); const [showPenaltyWarning, setShowPenaltyWarning] = useState(false); const [penaltyWarningData, setPenaltyWarningData] = useState(null); @@ -390,28 +383,6 @@ function SuiviLivraison() { } }; - const handleUpdateAddress = async (orderId: number) => { - setSavingAddress(true); - try { - const res = await updateOwnCommandAddress( - orderId, - newAddressValue, - ); - if (res.success) { - showToast(res.message || "Adresse mise à jour", "success"); - setEditingAddressOrder(null); - setNewAddressValue(""); - loadOrders(); - } else { - showToast(res.message || "Erreur", "error"); - } - } catch { - showToast("Erreur mise à jour adresse", "error"); - } finally { - setSavingAddress(false); - } - }; - const showToast = ( message: string, type: "success" | "error" | "warning" | "info", @@ -918,88 +889,6 @@ function SuiviLivraison() { order, )}

- {(statusLow === - "pending" || - statusLow === - "assigned") && - (editingAddressOrder === - order.id ? ( -
- - setNewAddressValue( - e - .target - .value, - ) - } - placeholder="Ex: 24 Rue Docteur Brindeau, 44000 Nantes" - /> -
- - -
-
- ) : ( - - ))} {(() => { const info = getClientInfo( diff --git a/mobile/app.json b/mobile/app.json index d924ba68..0a0d1683 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Milieu Nantais", "slug": "frontend-client", - "version": "1.0.0", + "version": "1.0.2", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "dark", @@ -52,7 +52,7 @@ "router": {} }, "owner": "xor290", - "runtimeVersion": "client-1.0.0", + "runtimeVersion": "client-1.0.2", "updates": { "url": "https://u.expo.dev/110d06c8-a8d5-4b3c-b262-0d4d7509ae9d", "codeSigningCertificate": "./certs/certificate.pem", diff --git a/mobile/certs/certificate.pem b/mobile/certs/certificate.pem index d5c1f25c..a53e9f6b 100644 --- a/mobile/certs/certificate.pem +++ b/mobile/certs/certificate.pem @@ -1,18 +1,18 @@ ------BEGIN CERTIFICATE----- -MIIC9zCCAd+gAwIBAgIJdh2LLVj8WJMXMA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV -BAMTGk1pbGlldSBOYW50YWlzIC8gVWJlciBTdHVwMB4XDTI2MDgxNjEyMTA0OVoX -DTM2MDgxNjEyMTA0OVowJTEjMCEGA1UEAxMaTWlsaWV1IE5hbnRhaXMgLyBVYmVy -IFN0dXAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCsUjXhTk71qOWK -XB+9jJ9FiP6+FXrPM9fj9a/75XjTNtIfS55m35rlkz4cfZvtjW1XziyMe+86/wGM -fg3iG8Zvu4DOS4aONFBz8kWosi6p2AAIPUQVIEdCEjL0V5r0MYfHvFu+GCLVbPeQ -hrd11Y6HwWEhCsKsK3OQXlN8PK9cpzeSeoCNdzA1iseFuAXoPu9Gm88lLThJgEvx -z95/piTa3OavRaYraP+ytd3/f87XbVrbtRGJq6sPIV9FHBt4z4AXcGwUIqTQ1Gwi -kMOaROMlQpzYlKS+yoZ72p6xs9dF0kBKsitlbavJ2E7ZrVoQh0Ufr4/6X26kKGwH -BvaH3E9vAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggr -BgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAE5sO2Nvgum66dib70UsbxTO7/8Os -yHy/RT7zUsgeVVGK18nIbC/SiZVQ9FNLw3ps0bIrXX968Ym4KJ1di5gcw5nuiVpf -1QNiN0t7vS6Gxaj2l9/TwoL409ud1kMZ2lXKyHnwVaNontNt7Y+e6HnOLeYNEepn -5v8qfwOa0H3e6o2T71uCepvVLCbmIngnpF5APkDNUbGnJFpoyxrrleNboQ8CZYY4 -uEsrPBZSRlhWLMLsJQOgLiRlnAYgqxS9C3DlxpZbsvA9eIDJuPquzUOuNX1KTMQl -My9rR/pXYvwd7xuOD+crkGRL6bLW9HRq9FWKT6JWEkNSagmyGxcPv/ilgQ== ------END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIC9zCCAd+gAwIBAgIJOs/S1ceI7Zq6MA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV +BAMTGk1pbGlldSBOYW50YWlzIC8gVWJlciBTdHVwMB4XDTI2MDcxMTIwNDI1MloX +DTM2MDcxMTIwNDI1MlowJTEjMCEGA1UEAxMaTWlsaWV1IE5hbnRhaXMgLyBVYmVy +IFN0dXAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQClkcj0h9gBuq9z +FVT1UwhBto2sTZglO3iwsgyPWx7I7twyY+kUU57rgHzI7YGieX599xM4oGjau8r7 +PjPK1160djTuRa9bWXnYBnotaU0Hp3rOicxbMygCGQoZtDqxRUMo4HxrBSYnZaVo +VYPqs/utSTA43El7SrzJddxBZK4WbJbfgdXDYrdeLz4Syrdx8DXBnCYmmmHhpQsE +orykYCUi7qd0CJi6kZGVOgR+Hq0B581DqnUA2H3iQWdk/0EZf6PN/gR0f9YlH3oN +N8tYLo7TOScSmUNJ5T2hFEuWuS/O6JKUI6a7MpIOv7XYxNDYWT/Ae9QNT8GcqVcW +39j2xSvvAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggr +BgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAj+SMnO7/IFajlg6Uo6aJotM8vdPp +4Dgi18DURZ0evUsxm4lLHWQ//6zF8jVaqPwsKA9IuPW+8O8gC8iwCY3YfjbaC5Ad +vPlvpzU6bvaA//utLVVUlfkk87vs5QotJkshoImJJoDPfO/Q1yv1qrMHXPnGyyzV +K0K3rYeXVYMDeJ9y2742D+MEg0Zse7xmNcde2z5aUuFlK7ORBs03FohD2U5zUqUg +jaia/wN4lIMCdJJmoPRUydbLJ8yVns9whFxXU1eGqaFf27jBdI/nPMVmO1YPsxnk +VvOiP6n1T+aZ7qaeOY9hsSmJ9FBeh3pOtRrdxmA8wBxD3zAORfLl89mjsw== +-----END CERTIFICATE----- diff --git a/mobile/eas.json b/mobile/eas.json index 5a1dd1c0..3fcde482 100644 --- a/mobile/eas.json +++ b/mobile/eas.json @@ -4,6 +4,18 @@ "appVersionSource": "local" }, "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleDebug" + }, + "env": { + "EXPO_PUBLIC_API_URL": "http://localhost:8080" + }, + "channel": "development" + }, "pre-prod": { "distribution": "internal", "android": { @@ -11,7 +23,7 @@ }, "env": { "EXPO_PUBLIC_API_URL": "https://uber-demo.club", - "EXPO_PUBLIC_UPDATE_URL": "https://ota-mobile-preprod.uber-stup.club/api/manifest" + "EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest" }, "channel": "pre-prod-client" }, @@ -23,7 +35,7 @@ }, "env": { "EXPO_PUBLIC_API_URL": "https://mln-uber.club", - "EXPO_PUBLIC_UPDATE_URL": "https://ota-mobile-prod.uber-stup.club/api/manifest" + "EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest" }, "channel": "production-client" } diff --git a/mobile/src/api/api.ts b/mobile/src/api/api.ts index 93bdfdb9..495487bf 100644 --- a/mobile/src/api/api.ts +++ b/mobile/src/api/api.ts @@ -457,31 +457,6 @@ export const respondToAddressProposal = async ( } }; -// Le client corrige lui-même l'adresse de sa commande (refusé si déjà en -// livraison ou terminée, cf. UpdateOwnCommandAddress côté backend). -export const updateOwnCommandAddress = async ( - commandId: number, - deliveryAddress: string, -): Promise<{ success: boolean; message: string }> => { - try { - const { data } = await apiClient.put( - `${V1}/commands/${commandId}/address`, - { delivery_address: deliveryAddress }, - ); - return { - success: true, - message: data.message || "Adresse mise à jour", - }; - } catch (error: any) { - return { - success: false, - message: - error.response?.data?.error || - "Erreur lors de la mise à jour de l'adresse", - }; - } -}; - export const getOrderTracking = async ( commandId: number, ): Promise => { @@ -989,6 +964,7 @@ export const toggle2FA = async ( export type RewardCategoryConfig = { category: string; + type: "free_product" | "half_price_product"; all_products: boolean; product_ids: number[]; product_names: string[]; @@ -1000,6 +976,7 @@ export type RewardItemConfig = { product_name: string; quantity: number; price: number; + type: "free_product" | "half_price_product"; }; export type PointsPoolInfo = { @@ -1015,7 +992,6 @@ export type PointsPoolInfo = { export type PointsRewardConfig = { threshold: number; - type: string; description: string; reward_items: RewardItemConfig[]; }; diff --git a/mobile/src/screens/client/OrderHistoryScreen.tsx b/mobile/src/screens/client/OrderHistoryScreen.tsx index a163ccf0..f1dc37a4 100644 --- a/mobile/src/screens/client/OrderHistoryScreen.tsx +++ b/mobile/src/screens/client/OrderHistoryScreen.tsx @@ -146,7 +146,7 @@ export default function OrderHistoryScreen() { if (res.success) { const text = res.product_added && res.product_name - ? `🎁 ${res.product_name} ajouté à votre panier ! Commandez au moins un produit pour en profiter.` + ? `${res.product_name} ajouté à votre panier ! Commandez au moins un produit pour en profiter.` : res.description || "Récompense réclamée !"; setClaimFeedback({ pool: poolKey, type: "success", text }); getMyPointsRewards().then((r) => { @@ -882,16 +882,34 @@ export default function OrderHistoryScreen() { : `Encore ${remaining} pts pour une récompense`} {feedback && ( - - {feedback.text} - + {feedback.type === + "success" && ( + + )} + + {feedback.text} + + )} {pool.rewards_available > 0 && ( )} - {item.price > 0 && ( + {item.type === + "half_price_product" ? ( - {item.price}€ + -50% · {item.price}€ + + ) : ( + + Offert )} (null); const [cancelReason, setCancelReason] = useState(""); const [cancelLoading, setCancelLoading] = useState(false); - const [editingAddressId, setEditingAddressId] = useState( - null, - ); - const [newAddress, setNewAddress] = useState(""); - const [editAddressLoading, setEditAddressLoading] = useState(false); const [penaltyWarning, setPenaltyWarning] = useState(null); const [penaltyOrderId, setPenaltyOrderId] = useState(null); @@ -193,25 +187,6 @@ export default function OrderTrackingScreen() { } }; - const handleUpdateAddress = async (orderId: number) => { - setEditAddressLoading(true); - try { - const res = await updateOwnCommandAddress(orderId, newAddress); - if (res.success) { - showToast(res.message || "Adresse mise à jour", "success"); - setEditingAddressId(null); - setNewAddress(""); - fetchOrders(); - } else { - showToast(res.message || "Erreur", "error"); - } - } catch { - showToast("Erreur mise à jour adresse", "error"); - } finally { - setEditAddressLoading(false); - } - }; - const styles = useMemo( () => StyleSheet.create({ @@ -432,12 +407,6 @@ export default function OrderTrackingScreen() { "assigned", "en_route", ].includes(order.status); - // Corrigeable uniquement avant la prise en charge par - // un livreur (cf. UpdateOwnCommandAddress backend). - const canEditAddress = [ - "pending", - "assigned", - ].includes(order.status); return ( )} - {canEditAddress && ( -