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"
)
// 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)
+4 -3
View File
@@ -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"],
}
}
+101 -17
View File
@@ -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
+13 -6
View File
@@ -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" }
+9 -6
View File
@@ -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
@@ -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{
{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)
+55 -10
View File
@@ -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)
+8 -3
View File
@@ -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)
@@ -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) {
+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 /api/v1/my-cancellation-history
@@ -2217,6 +2168,7 @@ export const unlinkTelegram = async (): Promise<void> => {
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[];
};
+8 -3
View File
@@ -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() {
<p className="cart-row-name">
{item.name_product}
{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" }}>
🎁 Récompense
<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" }}>
<FontAwesomeIcon icon={faGift} />
Récompense
</span>
)}
</p>
<p className="cart-row-qty">{item.quantity}g</p>
<p className="cart-row-price">
{item.is_reward ? (
{item.is_reward && item.price === 0 ? (
<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)}`
)}
@@ -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() {
<p
className={`reward-feedback reward-feedback-${feedback.type}`}
>
{feedback.type ===
"success" && (
<FontAwesomeIcon
icon={faGift}
style={{
marginRight: 6,
}}
/>
)}
{feedback.text}
</p>
)}
@@ -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)}`
: ""}
</span>
</div>
<span className="reward-product-free">
Offert
{item.type ===
"half_price_product"
? "-50%"
: "Offert"}
</span>
</button>
);
@@ -595,3 +595,54 @@
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 { 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<number | null>(null);
const [selectedPrice, setSelectedPrice] = useState<number>(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() {
</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;
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;
@@ -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<number | null>(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<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 = (
message: string,
type: "success" | "error" | "warning" | "info",
@@ -918,88 +889,6 @@ function SuiviLivraison() {
order,
)}
</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 =
getClientInfo(
+2 -2
View File
@@ -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",
+16 -16
View File
@@ -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==
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-----
+14 -2
View File
@@ -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"
}
+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 (
commandId: number,
): Promise<TrackingResponse> => {
@@ -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[];
};
@@ -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`}
</Text>
{feedback && (
<Text
style={
feedback.type ===
"success"
? styles.feedbackSuccess
: styles.feedbackError
}
<View
style={{
flexDirection:
"row",
alignItems:
"center",
gap: 4,
}}
>
{feedback.text}
</Text>
{feedback.type ===
"success" && (
<Ionicons
name="gift-outline"
size={12}
color="#10b981"
/>
)}
<Text
style={
feedback.type ===
"success"
? styles.feedbackSuccess
: styles.feedbackError
}
>
{feedback.text}
</Text>
</View>
)}
{pool.rewards_available > 0 && (
<TouchableOpacity
@@ -1132,11 +1150,18 @@ export default function OrderHistoryScreen() {
</Text>
)}
</View>
{item.price > 0 && (
{item.type ===
"half_price_product" ? (
<Text
style={styles.pickerItemPrice}
>
{item.price}
-50% · {item.price}
</Text>
) : (
<Text
style={styles.pickerItemPrice}
>
Offert
</Text>
)}
<Ionicons
@@ -17,7 +17,6 @@ import {
confirmReception,
cancelCommand,
respondToAddressProposal,
updateOwnCommandAddress,
formatOrderDate,
formatPrice,
calculateOrderTotal,
@@ -71,11 +70,6 @@ export default function OrderTrackingScreen() {
const [cancellingId, setCancellingId] = useState<number | null>(null);
const [cancelReason, setCancelReason] = useState("");
const [cancelLoading, setCancelLoading] = useState(false);
const [editingAddressId, setEditingAddressId] = useState<number | null>(
null,
);
const [newAddress, setNewAddress] = useState("");
const [editAddressLoading, setEditAddressLoading] = useState(false);
const [penaltyWarning, setPenaltyWarning] =
useState<CancelCommandResponse | 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(
() =>
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 (
<TouchableOpacity
@@ -667,23 +636,6 @@ export default function OrderTrackingScreen() {
size="sm"
/>
)}
{canEditAddress && (
<Button
title="Modifier l'adresse"
onPress={() => {
setNewAddress(
order.delivery_address ||
order.adresse ||
"",
);
setEditingAddressId(
order.id,
);
}}
variant="outline"
size="sm"
/>
)}
{canCancel && (
<Button
title="Annuler"
@@ -815,51 +767,6 @@ export default function OrderTrackingScreen() {
</View>
</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
visible={penaltyWarning !== null}
onClose={() => {