chore: build
Backend - Build & Lint / build (push) Failing after 30m2s
Frontend Client - EAS Build / build (push) Canceled after 7m55s
Frontend Web - Build & Lint / build (push) Failing after 10m18s

This commit is contained in:
Xor290
2026-08-21 13:08:56 +02:00
parent b9073954f1
commit 46652bb925
23 changed files with 549 additions and 404 deletions
+24 -3
View File
@@ -7,6 +7,25 @@ import (
"gorm.io/gorm" "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) { func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
var result struct { var result struct {
Price float64 `gorm:"column:price"` Price float64 `gorm:"column:price"`
@@ -42,7 +61,9 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
return baskets, nil 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. // 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. // 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) { 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 var basket models.Panier
if err := tx.Raw(` if err := tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at) 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`, 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 return nil, err
} }
baskets = append(baskets, basket) baskets = append(baskets, basket)
+1
View File
@@ -158,6 +158,7 @@ func GetDeliveryDetails(c *gin.Context) {
"produit": item["produit"], "produit": item["produit"],
"quantite": item["quantite"], "quantite": item["quantite"],
"prix": item["prix"], "prix": item["prix"],
"is_reward": item["is_reward"],
} }
} }
+101 -17
View File
@@ -1,22 +1,35 @@
package handlers package handlers
import ( import (
"fmt"
"gestion/db" "gestion/db"
"gestion/models" "gestion/models"
"gestion/utils" "gestion/utils"
"log" "log"
"math"
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// eligibleRewardProductIDs détermine, pour un pool donné, quels product_id de // normalizeRewardCategoryType retombe sur "free_product" pour toute valeur
// reward.RewardItems sont éligibles : sa catégorie (via CategoryConfigs) doit // vide ou inconnue — rétrocompatibilité avec les configurations enregistrées
// faire partie des catégories du pool, soit par whitelist explicite (ProductIDs) // avant l'introduction du type par catégorie (RewardCategoryConfig.Type).
// soit par correspondance de catégorie produit (AllProducts). func normalizeRewardCategoryType(t string) string {
func eligibleRewardProductIDs(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]bool { if t == "half_price_product" {
eligible := make(map[int]bool) 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 { if reward == nil {
return eligible return eligible
} }
@@ -24,21 +37,62 @@ func eligibleRewardProductIDs(reward *models.PointsReward, poolCategories map[st
if !poolCategories[cfg.Category] { if !poolCategories[cfg.Category] {
continue continue
} }
rewardType := normalizeRewardCategoryType(cfg.Type)
if cfg.AllProducts { if cfg.AllProducts {
for pid, cat := range productCategories { for pid, cat := range productCategories {
if cat == cfg.Category { if cat == cfg.Category {
eligible[pid] = true eligible[pid] = rewardType
} }
} }
} else { } else {
for _, pid := range cfg.ProductIDs { for _, pid := range cfg.ProductIDs {
eligible[pid] = true eligible[pid] = rewardType
} }
} }
} }
return eligible 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é. // 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. // La récompense est globale : son seuil s'applique indépendamment à chaque pool.
func GetMyPointsRewards(c *gin.Context) { func GetMyPointsRewards(c *gin.Context) {
@@ -71,6 +125,7 @@ func GetMyPointsRewards(c *gin.Context) {
type EligibleConfigResponse struct { type EligibleConfigResponse struct {
Category string `json:"category"` Category string `json:"category"`
Type string `json:"type"`
AllProducts bool `json:"all_products"` AllProducts bool `json:"all_products"`
ProductIDs []int `json:"product_ids"` ProductIDs []int `json:"product_ids"`
ProductNames []string `json:"product_names"` ProductNames []string `json:"product_names"`
@@ -81,6 +136,7 @@ func GetMyPointsRewards(c *gin.Context) {
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
Price float64 `json:"price"` Price float64 `json:"price"`
Type string `json:"type"`
} }
type PoolInfo struct { type PoolInfo struct {
@@ -141,6 +197,7 @@ func GetMyPointsRewards(c *gin.Context) {
} }
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{ eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
Category: cfg.Category, Category: cfg.Category,
Type: normalizeRewardCategoryType(cfg.Type),
AllProducts: cfg.AllProducts, AllProducts: cfg.AllProducts,
ProductIDs: cfg.ProductIDs, ProductIDs: cfg.ProductIDs,
ProductNames: names, 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) eligibleRewardItems := make([]RewardItemResponse, 0)
if reward != nil { if reward != nil {
for _, item := range reward.RewardItems { 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 continue
} }
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{ eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
ProductID: item.ProductID, ProductID: item.ProductID,
ProductName: productNames[item.ProductID], ProductName: productNames[item.ProductID],
Quantity: item.Quantity, 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 var rewardMeta gin.H
if reward != nil { if reward != nil {
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems)) rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
@@ -184,17 +250,22 @@ func GetMyPointsRewards(c *gin.Context) {
if item.ProductID <= 0 { if item.ProductID <= 0 {
continue 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] name := productNames[item.ProductID]
rewardItems = append(rewardItems, RewardItemResponse{ rewardItems = append(rewardItems, RewardItemResponse{
ProductID: item.ProductID, ProductID: item.ProductID,
ProductName: name, ProductName: name,
Quantity: item.Quantity, Quantity: item.Quantity,
Price: item.Price, Price: price,
Type: rewardType,
}) })
} }
rewardMeta = gin.H{ rewardMeta = gin.H{
"threshold": reward.Threshold, "threshold": reward.Threshold,
"type": reward.Type,
"description": reward.Description, "description": reward.Description,
"reward_items": rewardItems, "reward_items": rewardItems,
} }
@@ -273,13 +344,26 @@ func ClaimMyReward(c *gin.Context) {
return 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)) eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems))
for _, item := range reward.RewardItems { for _, item := range reward.RewardItems {
if eligibleProductIDs[item.ProductID] { rewardType, ok := eligibleProducts[item.ProductID]
eligibleItems = append(eligibleItems, item) 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 itemsToAdd := eligibleItems
+8 -1
View File
@@ -24,7 +24,14 @@ type ProductPrice struct {
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"` Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
Price float64 `json:"price" gorm:"column:price" binding:"required"` Price float64 `json:"price" gorm:"column:price" binding:"required"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
ActivePrice bool `json:"active_price" gorm:"column:active_price;default:true"` // 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" } func (ProductPrice) TableName() string { return "product_prices" }
+7 -4
View File
@@ -14,9 +14,11 @@ type PointsTier struct {
Points int `json:"points"` 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 { type RewardCategoryConfig struct {
Category string `json:"category"` // nom de la catégorie 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 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 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 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 { type PointsReward struct {
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20) 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 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 RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim
} }
@@ -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)
}
}
@@ -204,7 +204,7 @@ func TestCalculateAndAddPointsForCommandTx_RewardItemDeductsThresholdFromPoolPoi
setPointsPoolsSettings(t, []models.PointsPool{ setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 3}}}, {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 setClientPoolPoints(t, username, "pool_0", 25) // solde de départ avant cette commande
cmdID := newTestCommandWithItem(t, username, "livre", "", paidProductID, 1, 10) cmdID := newTestCommandWithItem(t, username, "livre", "", paidProductID, 1, 10)
+48 -3
View File
@@ -24,9 +24,14 @@ func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.R
return c, rec 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) { func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
t.Helper() t.Helper()
settings := db.DefaultSettings() settings := db.DefaultSettings()
settings.PointsPools[0].Categories = []string{"test"}
settings.PointsReward = reward settings.PointsReward = reward
if err := testDB.UpdateSettings(settings); err != nil { if err := testDB.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings: %v", err) t.Fatalf("UpdateSettings: %v", err)
@@ -42,8 +47,8 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
configureRewardSettings(t, &models.PointsReward{ configureRewardSettings(t, &models.PointsReward{
Threshold: 20, Threshold: 20,
Type: "free_product",
Description: "Un produit offert", Description: "Un produit offert",
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
}) })
setClientPoolPoints(t, username, "pool_0", 20) setClientPoolPoints(t, username, "pool_0", 20)
@@ -75,6 +80,43 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
if len(rows) != 1 || rows[0].ProductID != rewardProductID { if len(rows) != 1 || rows[0].ProductID != rewardProductID {
t.Errorf("le produit récompense doit être dans le panier: %+v", rows) 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) { func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
@@ -84,7 +126,7 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
configureRewardSettings(t, &models.PointsReward{ configureRewardSettings(t, &models.PointsReward{
Threshold: 20, Threshold: 20,
Type: "free_product", CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
}) })
setClientPoolPoints(t, username, "pool_0", 5) setClientPoolPoints(t, username, "pool_0", 5)
@@ -109,7 +151,10 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T)
configureRewardSettings(t, &models.PointsReward{ configureRewardSettings(t, &models.PointsReward{
Threshold: 20, Threshold: 20,
Type: "free_product", // 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 RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
}) })
setClientPoolPoints(t, username, "pool_0", 20) setClientPoolPoints(t, username, "pool_0", 20)
+8 -3
View File
@@ -206,7 +206,12 @@ func TestClaimPoolRewardAndAddToBasket_RollsBackBothOnInvalidProduct(t *testing.
// ── AddRewardsToBasket : flags et remplacement ────────────────────────────── // ── 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) cleanupStockTestData(t)
username := newTestClient(t, "reward_basket_flags") username := newTestClient(t, "reward_basket_flags")
productID := newTestProduct(t, "RewardBasketFlags", 20) productID := newTestProduct(t, "RewardBasketFlags", 20)
@@ -231,8 +236,8 @@ func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) {
if row.RewardPoolKey != "pool_0" { if row.RewardPoolKey != "pool_0" {
t.Errorf("reward_pool_key: got=%q want=%q", row.RewardPoolKey, "pool_0") t.Errorf("reward_pool_key: got=%q want=%q", row.RewardPoolKey, "pool_0")
} }
if row.Price != 0 { if row.Price != 15.0 {
t.Errorf("prix affiché doit être 0 (gratuit): got=%.2f", row.Price) t.Errorf("le prix fourni par l'appelant doit être stocké tel quel: got=%.2f want=15.00", row.Price)
} }
if row.Quantity != 2 { if row.Quantity != 2 {
t.Errorf("quantité: got=%.2f want=2", row.Quantity) t.Errorf("quantité: got=%.2f want=2", row.Quantity)
@@ -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 ──────────────────────────────────────────────────────── // ── DeleteProduct ────────────────────────────────────────────────────────
func TestDeleteProduct_RemovesProductWithoutMedia(t *testing.T) { func TestDeleteProduct_RemovesProductWithoutMedia(t *testing.T) {
+2 -50
View File
@@ -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 MY CANCELLATION HISTORY - Historique des annulations
* GET /api/v1/my-cancellation-history * GET /api/v1/my-cancellation-history
@@ -2217,6 +2168,7 @@ export const unlinkTelegram = async (): Promise<void> => {
export type RewardCategoryConfig = { export type RewardCategoryConfig = {
category: string; category: string;
type: "free_product" | "half_price_product";
all_products: boolean; all_products: boolean;
product_ids: number[]; product_ids: number[];
product_names: string[]; product_names: string[];
@@ -2228,6 +2180,7 @@ export type RewardItemConfig = {
product_name: string; product_name: string;
quantity: number; quantity: number;
price: number; price: number;
type: "free_product" | "half_price_product";
}; };
export type PointsPoolInfo = { export type PointsPoolInfo = {
@@ -2243,7 +2196,6 @@ export type PointsPoolInfo = {
export type PointsRewardConfig = { export type PointsRewardConfig = {
threshold: number; threshold: number;
type: string;
description: string; description: string;
reward_items: RewardItemConfig[]; reward_items: RewardItemConfig[];
}; };
+8 -3
View File
@@ -5,6 +5,8 @@ import { useNavigate } from "react-router-dom";
import { isUserAuthenticated, getProductById, getMediaUrl } from "../../api/api"; import { isUserAuthenticated, getProductById, getMediaUrl } from "../../api/api";
import type { Product } from "../../api/api"; import type { Product } from "../../api/api";
import { Trash2, ShoppingCart, AlertTriangle, Leaf, X } from "lucide-react"; 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"; import "./Cart.css";
interface CartItemWithMedia { interface CartItemWithMedia {
@@ -180,15 +182,18 @@ function Cart() {
<p className="cart-row-name"> <p className="cart-row-name">
{item.name_product} {item.name_product}
{item.is_reward && ( {item.is_reward && (
<span style={{ marginLeft: "6px", fontSize: "0.7rem", fontWeight: 700, color: "#f59e0b", background: "rgba(245,158,11,0.12)", borderRadius: "4px", padding: "1px 6px" }}> <span style={{ display: "inline-flex", alignItems: "center", gap: "4px", marginLeft: "6px", fontSize: "0.7rem", fontWeight: 700, color: "#f59e0b", background: "rgba(245,158,11,0.12)", borderRadius: "4px", padding: "1px 6px" }}>
🎁 Récompense <FontAwesomeIcon icon={faGift} />
Récompense
</span> </span>
)} )}
</p> </p>
<p className="cart-row-qty">{item.quantity}g</p> <p className="cart-row-qty">{item.quantity}g</p>
<p className="cart-row-price"> <p className="cart-row-price">
{item.is_reward ? ( {item.is_reward && item.price === 0 ? (
<span style={{ color: "#10b981", fontWeight: 700 }}>Offert</span> <span style={{ color: "#10b981", fontWeight: 700 }}>Offert</span>
) : item.is_reward ? (
<span style={{ color: "#10b981", fontWeight: 700 }}>{item.price.toFixed(2)} </span>
) : ( ) : (
`${item.price.toFixed(2)}` `${item.price.toFixed(2)}`
)} )}
@@ -210,7 +210,7 @@ function ConsultationHistorique() {
if (res.success) { if (res.success) {
const text = const text =
res.product_added && res.product_names?.length 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 !"; : res.description || "Récompense réclamée !";
setClaimFeedback({ pool: poolKey, type: "success", text }); setClaimFeedback({ pool: poolKey, type: "success", text });
getMyPointsRewards().then((r) => { getMyPointsRewards().then((r) => {
@@ -505,6 +505,15 @@ function ConsultationHistorique() {
<p <p
className={`reward-feedback reward-feedback-${feedback.type}`} className={`reward-feedback reward-feedback-${feedback.type}`}
> >
{feedback.type ===
"success" && (
<FontAwesomeIcon
icon={faGift}
style={{
marginRight: 6,
}}
/>
)}
{feedback.text} {feedback.text}
</p> </p>
)} )}
@@ -747,13 +756,18 @@ function ConsultationHistorique() {
item.quantity !== 1 item.quantity !== 1
? `×${item.quantity}` ? `×${item.quantity}`
: ""} : ""}
{item.price > 0 {item.type ===
? ` · valeur ${item.price.toFixed(2)}` "half_price_product" &&
item.price > 0
? ` · ${item.price.toFixed(2)}`
: ""} : ""}
</span> </span>
</div> </div>
<span className="reward-product-free"> <span className="reward-product-free">
Offert {item.type ===
"half_price_product"
? "-50%"
: "Offert"}
</span> </span>
</button> </button>
); );
@@ -595,3 +595,54 @@
transition-duration: 0.01ms !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;
}
@@ -1,5 +1,7 @@
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { import {
getProductById, getProductById,
getCategories, getCategories,
@@ -25,6 +27,7 @@ function ProductDetail() {
// floats // floats
const [selectedGrams, setSelectedGrams] = useState<number | null>(null); const [selectedGrams, setSelectedGrams] = useState<number | null>(null);
const [selectedPrice, setSelectedPrice] = useState<number>(0.0); const [selectedPrice, setSelectedPrice] = useState<number>(0.0);
const [stockWarning, setStockWarning] = useState<{ wanted: number; available: number } | null>(null);
// ✅ TOAST STATE // ✅ TOAST STATE
const [toast, setToast] = useState<{ const [toast, setToast] = useState<{
@@ -171,6 +174,11 @@ function ProductDetail() {
return; return;
} }
if (product.stock > 0 && selectedGrams > product.stock) {
setStockWarning({ wanted: selectedGrams, available: product.stock });
return;
}
addToCart({ addToCart({
product_id: product.id, product_id: product.id,
name_product: product.name, name_product: product.name,
@@ -363,6 +371,50 @@ function ProductDetail() {
</div> </div>
</div> </div>
</div> </div>
{/* Modal stock insuffisant */}
{stockWarning &&
createPortal(
<div
className="stock-warning-modal"
onClick={() => setStockWarning(null)}
>
<div
className="stock-warning-modal-content"
onClick={(e) => e.stopPropagation()}
>
<button
className="stock-warning-close-btn"
onClick={() => setStockWarning(null)}
aria-label="Fermer"
>
<X size={18} />
</button>
<div style={{ fontSize: "2.5rem", marginBottom: "0.75rem" }}>
</div>
<p style={{ fontWeight: 700, fontSize: "1.1rem", marginBottom: "0.5rem" }}>
Stock insuffisant
</p>
<p style={{ color: "#aaa", fontSize: "0.95rem", marginBottom: "1.25rem" }}>
Vous avez sélectionné{" "}
<strong>{stockWarning.wanted}{product.unit || "g"}</strong> mais il ne
reste que{" "}
<strong style={{ color: "#ef4444" }}>
{stockWarning.available}{product.unit || "g"}
</strong>{" "}
disponible pour <em>{product.name}</em>.
</p>
<button
className="stock-warning-ok-btn"
onClick={() => setStockWarning(null)}
>
OK
</button>
</div>
</div>,
document.body,
)}
</> </>
); );
} }
@@ -489,40 +489,6 @@
font-weight: 600; font-weight: 600;
color: var(--text); 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 { .contact {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
@@ -14,7 +14,6 @@ import {
getOrderETA, getOrderETA,
confirmReception, confirmReception,
cancelCommand, cancelCommand,
updateOwnCommandAddress,
isUserAuthenticated, isUserAuthenticated,
getPublicSettings, getPublicSettings,
} from "../../api/api"; } from "../../api/api";
@@ -52,7 +51,6 @@ import {
faWind, faWind,
faChevronUp, faChevronUp,
faChevronDown, faChevronDown,
faPen,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
interface OrderWithTracking extends OrderDetail { interface OrderWithTracking extends OrderDetail {
@@ -284,11 +282,6 @@ function SuiviLivraison() {
const [showCancelDialog, setShowCancelDialog] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false);
const [orderToCancel, setOrderToCancel] = useState<number | null>(null); const [orderToCancel, setOrderToCancel] = useState<number | null>(null);
const [cancelReason, setCancelReason] = useState(""); 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 [showPenaltyWarning, setShowPenaltyWarning] = useState(false);
const [penaltyWarningData, setPenaltyWarningData] = const [penaltyWarningData, setPenaltyWarningData] =
useState<CancelCommandResponse | null>(null); useState<CancelCommandResponse | null>(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 = ( const showToast = (
message: string, message: string,
type: "success" | "error" | "warning" | "info", type: "success" | "error" | "warning" | "info",
@@ -918,88 +889,6 @@ function SuiviLivraison() {
order, order,
)} )}
</p> </p>
{(statusLow ===
"pending" ||
statusLow ===
"assigned") &&
(editingAddressOrder ===
order.id ? (
<div className="address-edit">
<input
type="text"
className="address-edit-input"
value={
newAddressValue
}
onChange={(
e,
) =>
setNewAddressValue(
e
.target
.value,
)
}
placeholder="Ex: 24 Rue Docteur Brindeau, 44000 Nantes"
/>
<div className="address-edit-actions">
<button
type="button"
className="btn-secondary"
onClick={() => {
setEditingAddressOrder(
null,
);
setNewAddressValue(
"",
);
}}
>
Annuler
</button>
<button
type="button"
className="btn-primary"
disabled={
savingAddress ||
!newAddressValue.trim()
}
onClick={() =>
handleUpdateAddress(
order.id,
)
}
>
{savingAddress
? "Enregistrement..."
: "Enregistrer"}
</button>
</div>
</div>
) : (
<button
type="button"
className="address-edit-toggle"
onClick={() => {
setNewAddressValue(
getDeliveryAddress(
order,
),
);
setEditingAddressOrder(
order.id,
);
}}
>
<FontAwesomeIcon
icon={
faPen
}
/>{" "}
Modifier
l'adresse
</button>
))}
{(() => { {(() => {
const info = const info =
getClientInfo( getClientInfo(
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Milieu Nantais", "name": "Milieu Nantais",
"slug": "frontend-client", "slug": "frontend-client",
"version": "1.0.0", "version": "1.0.2",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
@@ -52,7 +52,7 @@
"router": {} "router": {}
}, },
"owner": "xor290", "owner": "xor290",
"runtimeVersion": "client-1.0.0", "runtimeVersion": "client-1.0.2",
"updates": { "updates": {
"url": "https://u.expo.dev/110d06c8-a8d5-4b3c-b262-0d4d7509ae9d", "url": "https://u.expo.dev/110d06c8-a8d5-4b3c-b262-0d4d7509ae9d",
"codeSigningCertificate": "./certs/certificate.pem", "codeSigningCertificate": "./certs/certificate.pem",
+16 -16
View File
@@ -1,18 +1,18 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIC9zCCAd+gAwIBAgIJdh2LLVj8WJMXMA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV MIIC9zCCAd+gAwIBAgIJOs/S1ceI7Zq6MA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV
BAMTGk1pbGlldSBOYW50YWlzIC8gVWJlciBTdHVwMB4XDTI2MDgxNjEyMTA0OVoX BAMTGk1pbGlldSBOYW50YWlzIC8gVWJlciBTdHVwMB4XDTI2MDcxMTIwNDI1MloX
DTM2MDgxNjEyMTA0OVowJTEjMCEGA1UEAxMaTWlsaWV1IE5hbnRhaXMgLyBVYmVy DTM2MDcxMTIwNDI1MlowJTEjMCEGA1UEAxMaTWlsaWV1IE5hbnRhaXMgLyBVYmVy
IFN0dXAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCsUjXhTk71qOWK IFN0dXAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQClkcj0h9gBuq9z
XB+9jJ9FiP6+FXrPM9fj9a/75XjTNtIfS55m35rlkz4cfZvtjW1XziyMe+86/wGM FVT1UwhBto2sTZglO3iwsgyPWx7I7twyY+kUU57rgHzI7YGieX599xM4oGjau8r7
fg3iG8Zvu4DOS4aONFBz8kWosi6p2AAIPUQVIEdCEjL0V5r0MYfHvFu+GCLVbPeQ PjPK1160djTuRa9bWXnYBnotaU0Hp3rOicxbMygCGQoZtDqxRUMo4HxrBSYnZaVo
hrd11Y6HwWEhCsKsK3OQXlN8PK9cpzeSeoCNdzA1iseFuAXoPu9Gm88lLThJgEvx VYPqs/utSTA43El7SrzJddxBZK4WbJbfgdXDYrdeLz4Syrdx8DXBnCYmmmHhpQsE
z95/piTa3OavRaYraP+ytd3/f87XbVrbtRGJq6sPIV9FHBt4z4AXcGwUIqTQ1Gwi orykYCUi7qd0CJi6kZGVOgR+Hq0B581DqnUA2H3iQWdk/0EZf6PN/gR0f9YlH3oN
kMOaROMlQpzYlKS+yoZ72p6xs9dF0kBKsitlbavJ2E7ZrVoQh0Ufr4/6X26kKGwH N8tYLo7TOScSmUNJ5T2hFEuWuS/O6JKUI6a7MpIOv7XYxNDYWT/Ae9QNT8GcqVcW
BvaH3E9vAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggr 39j2xSvvAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggr
BgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAE5sO2Nvgum66dib70UsbxTO7/8Os BgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAj+SMnO7/IFajlg6Uo6aJotM8vdPp
yHy/RT7zUsgeVVGK18nIbC/SiZVQ9FNLw3ps0bIrXX968Ym4KJ1di5gcw5nuiVpf 4Dgi18DURZ0evUsxm4lLHWQ//6zF8jVaqPwsKA9IuPW+8O8gC8iwCY3YfjbaC5Ad
1QNiN0t7vS6Gxaj2l9/TwoL409ud1kMZ2lXKyHnwVaNontNt7Y+e6HnOLeYNEepn vPlvpzU6bvaA//utLVVUlfkk87vs5QotJkshoImJJoDPfO/Q1yv1qrMHXPnGyyzV
5v8qfwOa0H3e6o2T71uCepvVLCbmIngnpF5APkDNUbGnJFpoyxrrleNboQ8CZYY4 K0K3rYeXVYMDeJ9y2742D+MEg0Zse7xmNcde2z5aUuFlK7ORBs03FohD2U5zUqUg
uEsrPBZSRlhWLMLsJQOgLiRlnAYgqxS9C3DlxpZbsvA9eIDJuPquzUOuNX1KTMQl jaia/wN4lIMCdJJmoPRUydbLJ8yVns9whFxXU1eGqaFf27jBdI/nPMVmO1YPsxnk
My9rR/pXYvwd7xuOD+crkGRL6bLW9HRq9FWKT6JWEkNSagmyGxcPv/ilgQ== VvOiP6n1T+aZ7qaeOY9hsSmJ9FBeh3pOtRrdxmA8wBxD3zAORfLl89mjsw==
-----END CERTIFICATE----- -----END CERTIFICATE-----
+14 -2
View File
@@ -4,6 +4,18 @@
"appVersionSource": "local" "appVersionSource": "local"
}, },
"build": { "build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleDebug"
},
"env": {
"EXPO_PUBLIC_API_URL": "http://localhost:8080"
},
"channel": "development"
},
"pre-prod": { "pre-prod": {
"distribution": "internal", "distribution": "internal",
"android": { "android": {
@@ -11,7 +23,7 @@
}, },
"env": { "env": {
"EXPO_PUBLIC_API_URL": "https://uber-demo.club", "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" "channel": "pre-prod-client"
}, },
@@ -23,7 +35,7 @@
}, },
"env": { "env": {
"EXPO_PUBLIC_API_URL": "https://mln-uber.club", "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" "channel": "production-client"
} }
+2 -26
View File
@@ -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 ( export const getOrderTracking = async (
commandId: number, commandId: number,
): Promise<TrackingResponse> => { ): Promise<TrackingResponse> => {
@@ -989,6 +964,7 @@ export const toggle2FA = async (
export type RewardCategoryConfig = { export type RewardCategoryConfig = {
category: string; category: string;
type: "free_product" | "half_price_product";
all_products: boolean; all_products: boolean;
product_ids: number[]; product_ids: number[];
product_names: string[]; product_names: string[];
@@ -1000,6 +976,7 @@ export type RewardItemConfig = {
product_name: string; product_name: string;
quantity: number; quantity: number;
price: number; price: number;
type: "free_product" | "half_price_product";
}; };
export type PointsPoolInfo = { export type PointsPoolInfo = {
@@ -1015,7 +992,6 @@ export type PointsPoolInfo = {
export type PointsRewardConfig = { export type PointsRewardConfig = {
threshold: number; threshold: number;
type: string;
description: string; description: string;
reward_items: RewardItemConfig[]; reward_items: RewardItemConfig[];
}; };
@@ -146,7 +146,7 @@ export default function OrderHistoryScreen() {
if (res.success) { if (res.success) {
const text = const text =
res.product_added && res.product_name 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 !"; : res.description || "Récompense réclamée !";
setClaimFeedback({ pool: poolKey, type: "success", text }); setClaimFeedback({ pool: poolKey, type: "success", text });
getMyPointsRewards().then((r) => { getMyPointsRewards().then((r) => {
@@ -882,6 +882,23 @@ export default function OrderHistoryScreen() {
: `Encore ${remaining} pts pour une récompense`} : `Encore ${remaining} pts pour une récompense`}
</Text> </Text>
{feedback && ( {feedback && (
<View
style={{
flexDirection:
"row",
alignItems:
"center",
gap: 4,
}}
>
{feedback.type ===
"success" && (
<Ionicons
name="gift-outline"
size={12}
color="#10b981"
/>
)}
<Text <Text
style={ style={
feedback.type === feedback.type ===
@@ -892,6 +909,7 @@ export default function OrderHistoryScreen() {
> >
{feedback.text} {feedback.text}
</Text> </Text>
</View>
)} )}
{pool.rewards_available > 0 && ( {pool.rewards_available > 0 && (
<TouchableOpacity <TouchableOpacity
@@ -1132,11 +1150,18 @@ export default function OrderHistoryScreen() {
</Text> </Text>
)} )}
</View> </View>
{item.price > 0 && ( {item.type ===
"half_price_product" ? (
<Text <Text
style={styles.pickerItemPrice} style={styles.pickerItemPrice}
> >
{item.price} -50% · {item.price}
</Text>
) : (
<Text
style={styles.pickerItemPrice}
>
Offert
</Text> </Text>
)} )}
<Ionicons <Ionicons
@@ -17,7 +17,6 @@ import {
confirmReception, confirmReception,
cancelCommand, cancelCommand,
respondToAddressProposal, respondToAddressProposal,
updateOwnCommandAddress,
formatOrderDate, formatOrderDate,
formatPrice, formatPrice,
calculateOrderTotal, calculateOrderTotal,
@@ -71,11 +70,6 @@ export default function OrderTrackingScreen() {
const [cancellingId, setCancellingId] = useState<number | null>(null); const [cancellingId, setCancellingId] = useState<number | null>(null);
const [cancelReason, setCancelReason] = useState(""); const [cancelReason, setCancelReason] = useState("");
const [cancelLoading, setCancelLoading] = useState(false); const [cancelLoading, setCancelLoading] = useState(false);
const [editingAddressId, setEditingAddressId] = useState<number | null>(
null,
);
const [newAddress, setNewAddress] = useState("");
const [editAddressLoading, setEditAddressLoading] = useState(false);
const [penaltyWarning, setPenaltyWarning] = const [penaltyWarning, setPenaltyWarning] =
useState<CancelCommandResponse | null>(null); useState<CancelCommandResponse | null>(null);
const [penaltyOrderId, setPenaltyOrderId] = useState<number | null>(null); const [penaltyOrderId, setPenaltyOrderId] = useState<number | null>(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( const styles = useMemo(
() => () =>
StyleSheet.create({ StyleSheet.create({
@@ -432,12 +407,6 @@ export default function OrderTrackingScreen() {
"assigned", "assigned",
"en_route", "en_route",
].includes(order.status); ].includes(order.status);
// Corrigeable uniquement avant la prise en charge par
// un livreur (cf. UpdateOwnCommandAddress backend).
const canEditAddress = [
"pending",
"assigned",
].includes(order.status);
return ( return (
<TouchableOpacity <TouchableOpacity
@@ -667,23 +636,6 @@ export default function OrderTrackingScreen() {
size="sm" size="sm"
/> />
)} )}
{canEditAddress && (
<Button
title="Modifier l'adresse"
onPress={() => {
setNewAddress(
order.delivery_address ||
order.adresse ||
"",
);
setEditingAddressId(
order.id,
);
}}
variant="outline"
size="sm"
/>
)}
{canCancel && ( {canCancel && (
<Button <Button
title="Annuler" title="Annuler"
@@ -815,51 +767,6 @@ export default function OrderTrackingScreen() {
</View> </View>
</Modal> </Modal>
<Modal
visible={editingAddressId !== null}
onClose={() => {
setEditingAddressId(null);
setNewAddress("");
}}
title="Modifier l'adresse"
icon="location-outline"
>
<View style={styles.modalBody}>
<Text style={styles.modalText}>
Nouvelle adresse de livraison :
</Text>
<RNTextInput
style={styles.cancelInput}
placeholder="Ex: 24 Rue Docteur Brindeau, 44000 Nantes"
placeholderTextColor={colors.textMuted}
value={newAddress}
onChangeText={setNewAddress}
multiline
/>
<View style={styles.modalActions}>
<Button
title="Retour"
onPress={() => {
setEditingAddressId(null);
setNewAddress("");
}}
variant="outline"
size="md"
/>
<Button
title="Enregistrer"
onPress={() =>
editingAddressId &&
handleUpdateAddress(editingAddressId)
}
loading={editAddressLoading}
variant="success"
size="md"
/>
</View>
</View>
</Modal>
<Modal <Modal
visible={penaltyWarning !== null} visible={penaltyWarning !== null}
onClose={() => { onClose={() => {