From 46652bb9256d3b5e521afe97a8a9f5a4018012a3 Mon Sep 17 00:00:00 2001
From: Xor290
Date: Fri, 21 Aug 2026 13:08:56 +0200
Subject: [PATCH] chore: build
---
backend/gestion/db/db_basket.go | 27 +++-
backend/gestion/handlers/deleviry.go | 7 +-
backend/gestion/handlers/points.go | 118 +++++++++++++++---
backend/gestion/models/product.go | 19 ++-
backend/gestion/models/settings.go | 15 ++-
.../gestion/tests/deleviry_details_test.go | 81 ++++++++++++
backend/gestion/tests/points_approval_test.go | 2 +-
backend/gestion/tests/rewards_handler_test.go | 65 ++++++++--
backend/gestion/tests/rewards_test.go | 11 +-
backend/gestion/tests/stock_coverage_test.go | 49 ++++++++
frontend-prep/src/api/api.ts | 52 +-------
frontend-prep/src/pages/User/Cart.tsx | 11 +-
.../src/pages/User/ConsultationHistorique.tsx | 22 +++-
.../src/pages/User/ProductDetail.css | 51 ++++++++
.../src/pages/User/ProductDetail.tsx | 52 ++++++++
.../src/pages/User/SuiviLivraison.css | 34 -----
.../src/pages/User/SuiviLivraison.tsx | 111 ----------------
mobile/app.json | 4 +-
mobile/certs/certificate.pem | 36 +++---
mobile/eas.json | 16 ++-
mobile/src/api/api.ts | 28 +----
.../src/screens/client/OrderHistoryScreen.tsx | 49 ++++++--
.../screens/client/OrderTrackingScreen.tsx | 93 --------------
23 files changed, 549 insertions(+), 404 deletions(-)
create mode 100644 backend/gestion/tests/deleviry_details_test.go
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 && (
-