chore: build
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -155,9 +155,10 @@ func GetDeliveryDetails(c *gin.Context) {
|
|||||||
itemsSummary := make([]gin.H, len(items))
|
itemsSummary := make([]gin.H, len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
itemsSummary[i] = gin.H{
|
itemsSummary[i] = gin.H{
|
||||||
"produit": item["produit"],
|
"produit": item["produit"],
|
||||||
"quantite": item["quantite"],
|
"quantite": item["quantite"],
|
||||||
"prix": item["prix"],
|
"prix": item["prix"],
|
||||||
|
"is_reward": item["is_reward"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -19,12 +19,19 @@ type Product struct {
|
|||||||
func (Product) TableName() string { return "products" }
|
func (Product) TableName() string { return "products" }
|
||||||
|
|
||||||
type ProductPrice struct {
|
type ProductPrice struct {
|
||||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
ProductID int `json:"product_id" gorm:"column:product_id;index"`
|
ProductID int `json:"product_id" gorm:"column:product_id;index"`
|
||||||
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" }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +102,8 @@ type AppSettings struct {
|
|||||||
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
||||||
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
||||||
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
|
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
|
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 @)
|
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
||||||
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
||||||
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
||||||
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
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{
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -41,10 +46,10 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
|
|||||||
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
|
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
|
||||||
|
|
||||||
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) {
|
||||||
@@ -83,9 +125,9 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
|
|||||||
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
|
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -108,9 +150,12 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T)
|
|||||||
username := newTestClient(t, "reward_http_missing_product")
|
username := newTestClient(t, "reward_http_missing_product")
|
||||||
|
|
||||||
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
|
||||||
RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
|
// 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)
|
setClientPoolPoints(t, username, "pool_0", 20)
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -594,4 +594,55 @@
|
|||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,16 +882,34 @@ export default function OrderHistoryScreen() {
|
|||||||
: `Encore ${remaining} pts pour une récompense`}
|
: `Encore ${remaining} pts pour une récompense`}
|
||||||
</Text>
|
</Text>
|
||||||
{feedback && (
|
{feedback && (
|
||||||
<Text
|
<View
|
||||||
style={
|
style={{
|
||||||
feedback.type ===
|
flexDirection:
|
||||||
"success"
|
"row",
|
||||||
? styles.feedbackSuccess
|
alignItems:
|
||||||
: styles.feedbackError
|
"center",
|
||||||
}
|
gap: 4,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{feedback.text}
|
{feedback.type ===
|
||||||
</Text>
|
"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 && (
|
{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={() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user