Compare commits
7
Commits
f3dfb7b2ae
...
pre-prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84dc153ec | ||
|
|
b6189ac787 | ||
|
|
9f5f709507 | ||
|
|
4fbed720e8 | ||
|
|
a7fe98830b | ||
|
|
f904a37964 | ||
|
|
c69dc70680 |
@@ -68,7 +68,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker-prod/backend/Dockerfile
|
||||
file: ${{ github.ref == 'refs/heads/main' && 'docker-prod/backend/Dockerfile' || 'docker-pre-prod/backend/Dockerfile' }}
|
||||
target: runtime
|
||||
push: true
|
||||
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'latest' || 'pre-prod' }}
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker-prod/backend/Dockerfile
|
||||
file: ${{ github.ref == 'refs/heads/main' && 'docker-prod/backend/Dockerfile' || 'docker-pre-prod/backend/Dockerfile' }}
|
||||
target: waf
|
||||
push: true
|
||||
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'waf' || 'waf-pre-prod' }}
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
echo "profile=production" >> $GITHUB_OUTPUT
|
||||
echo "channel=production-admin" >> $GITHUB_OUTPUT
|
||||
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "ota_api_url=https://mln-uber.club" >> $GITHUB_OUTPUT
|
||||
echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "xavia_url=https://ota-prod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||
echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PROD }}" >> $GITHUB_OUTPUT
|
||||
echo "apk_name=admin-panel-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
echo "profile=pre-prod" >> $GITHUB_OUTPUT
|
||||
echo "channel=pre-prod-admin" >> $GITHUB_OUTPUT
|
||||
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "ota_api_url=https://5.181.0.112.nip.io" >> $GITHUB_OUTPUT
|
||||
echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "xavia_url=https://ota-preprod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||
echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PREPROD }}" >> $GITHUB_OUTPUT
|
||||
echo "apk_name=admin-panel-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker-prod/frontend/Dockerfile
|
||||
file: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'docker-prod/frontend/Dockerfile' || 'docker-pre-prod/frontend/Dockerfile' }}
|
||||
push: true
|
||||
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
|
||||
build-args: |
|
||||
|
||||
@@ -18,7 +18,6 @@ func (d *Database) CheckAddress(addressByUser *models.Command) error {
|
||||
return fmt.Errorf("checkAddress: %w", result.Error)
|
||||
}
|
||||
|
||||
// Pas de correspondance exacte — fallback sur une comparaison normalisée
|
||||
// (accents/casse/espaces) pour rattraper les variantes mineures de saisie.
|
||||
corrections, err := d.AllAddress()
|
||||
if err != nil {
|
||||
|
||||
@@ -137,9 +137,6 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
||||
if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock: %w", err)
|
||||
}
|
||||
if productInfo.Stock < quantity {
|
||||
return fmt.Errorf("stock insuffisant")
|
||||
}
|
||||
|
||||
var priceResult struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
@@ -153,32 +150,49 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
||||
}
|
||||
// Une promotion active pour ce produit/quantité/catégorie s'applique
|
||||
// automatiquement au prix facturé — indépendamment des points de
|
||||
// fidélité (contrairement aux récompenses par palier).
|
||||
// fidélité (contrairement aux récompenses par palier). Le montant
|
||||
// économisé est conservé (promoDiscount) pour les statistiques
|
||||
// admin, indépendamment de la config de promo courante au moment où
|
||||
// ces stats seront consultées.
|
||||
var promoDiscount float64
|
||||
if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok {
|
||||
promoDiscount = priceResult.Price - discounted
|
||||
priceResult.Price = discounted
|
||||
}
|
||||
|
||||
// Offre "achetez X, Y offert" : le client reçoit une quantité
|
||||
// supplémentaire du même produit, gratuite, sans changer le prix déjà
|
||||
// calculé sur la quantité demandée — la quantité livrée/décomptée du
|
||||
// stock est donc supérieure à la quantité facturée.
|
||||
freeQuantity := d.ResolveFreeGiftQuantity(productID, productInfo.Category, quantity)
|
||||
deliveredQuantity := quantity + freeQuantity
|
||||
|
||||
if productInfo.Stock < deliveredQuantity {
|
||||
return fmt.Errorf("stock insuffisant")
|
||||
}
|
||||
|
||||
var existing struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
ID int `gorm:"column:id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||
}
|
||||
// Chercher uniquement un item normal (non-récompense) pour ce produit
|
||||
tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
|
||||
tx.Raw(`SELECT id, quantity, price, promo_discount FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
|
||||
username, productID).Scan(&existing)
|
||||
|
||||
if existing.ID != 0 {
|
||||
return tx.Raw(`
|
||||
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
||||
existing.Quantity+quantity, existing.Price+priceResult.Price,
|
||||
existing.ID).Scan(&basket).Error
|
||||
UPDATE baskets SET quantity = ?, price = ?, promo_discount = ?, created_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
|
||||
existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
|
||||
existing.PromoDiscount+promoDiscount, existing.ID).Scan(&basket).Error
|
||||
}
|
||||
return tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
|
||||
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
||||
username, productID, quantity, priceResult.Price).Scan(&basket).Error
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, promo_discount, created_at)
|
||||
VALUES (?, ?, ?, ?, false, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
|
||||
username, productID, deliveredQuantity, priceResult.Price, promoDiscount).Scan(&basket).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -19,6 +19,7 @@ type commandItemFull struct {
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
ClientNom string `gorm:"column:client_nom"`
|
||||
ClientPrenom string `gorm:"column:client_prenom"`
|
||||
|
||||
@@ -55,6 +55,7 @@ type basketItem struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||
}
|
||||
|
||||
// validateCommandStatus vérifie si le statut est valide
|
||||
@@ -109,7 +110,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
||||
// bloque ici puis échoue proprement ("panier vide") une fois le premier
|
||||
// passage terminé, au lieu de créer une commande fantôme.
|
||||
var basketItems []basketItem
|
||||
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
|
||||
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key, promo_discount FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
|
||||
return fmt.Errorf("erreur récupération panier: %w", err)
|
||||
}
|
||||
if len(basketItems) == 0 {
|
||||
@@ -162,6 +163,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
||||
Prix: item.Price,
|
||||
IsReward: item.IsReward,
|
||||
RewardPoolKey: item.RewardPoolKey,
|
||||
PromoDiscount: item.PromoDiscount,
|
||||
ClientUsername: username,
|
||||
ClientNom: clientNom,
|
||||
ClientPrenom: clientPrenom,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package db
|
||||
|
||||
import "gestion/models"
|
||||
|
||||
// ResolveFreeGift retourne la quantité offerte (du même produit) pour un
|
||||
// produit, sa catégorie catalogue et une quantité commandée donnés — le seuil
|
||||
// le plus élevé (BuyQuantity) atteint par la quantité commandée est retenu,
|
||||
// tous seuils confondus pour ce produit (ex: seuils 10g→+1g et 20g→+3g, une
|
||||
// commande de 25g retient +3g, pas +1g).
|
||||
func ResolveFreeGift(settings *models.AppSettings, productID int, category string, quantity float64) float64 {
|
||||
if settings == nil || !settings.FreeGiftsEnabled {
|
||||
return 0
|
||||
}
|
||||
|
||||
var bestBuy, bestFree float64
|
||||
found := false
|
||||
consider := func(tiers []models.FreeGiftTier) {
|
||||
for _, t := range tiers {
|
||||
if t.BuyQuantity <= 0 || t.FreeQuantity <= 0 || quantity < t.BuyQuantity {
|
||||
continue
|
||||
}
|
||||
if !found || t.BuyQuantity > bestBuy {
|
||||
bestBuy, bestFree = t.BuyQuantity, t.FreeQuantity
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, g := range settings.FreeGifts {
|
||||
if g.Category != category {
|
||||
continue
|
||||
}
|
||||
if g.AllProducts {
|
||||
consider(g.Tiers)
|
||||
continue
|
||||
}
|
||||
for _, pq := range g.Products {
|
||||
if pq.ProductID == productID {
|
||||
consider(pq.Tiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return 0
|
||||
}
|
||||
return bestFree
|
||||
}
|
||||
|
||||
// ResolveFreeGiftQuantity lit les settings courants et applique
|
||||
// ResolveFreeGift — wrapper pratique pour les appelants qui n'ont pas déjà
|
||||
// les settings sous la main (même style que ApplyPromotionToPrice).
|
||||
func (d *Database) ResolveFreeGiftQuantity(productID int, category string, quantity float64) float64 {
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return ResolveFreeGift(&settings, productID, category, quantity)
|
||||
}
|
||||
@@ -140,6 +140,20 @@ func InitDB() *Database {
|
||||
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
|
||||
}
|
||||
|
||||
// Migration: baskets.promo_discount + command_items.promo_discount —
|
||||
// montant (en €) économisé par une promotion de prix sur cette ligne,
|
||||
// capturé une fois pour toutes au moment de AddToBasket (voir
|
||||
// db_basket.go) puis copié tel quel au checkout, pour permettre des
|
||||
// statistiques historiques fiables même si la config de promo change
|
||||
// ensuite (contrairement à un recalcul a posteriori sur les settings
|
||||
// courants, qui donnerait un résultat faux pour les anciennes commandes).
|
||||
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.promo_discount: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.promo_discount: %v", err)
|
||||
}
|
||||
|
||||
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
||||
if _, err = database.Exec(`
|
||||
DO $$
|
||||
|
||||
@@ -149,6 +149,13 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
|
||||
settings.Promotions = promotions
|
||||
}
|
||||
case "free_gifts_enabled":
|
||||
settings.FreeGiftsEnabled = row.Value == "true"
|
||||
case "free_gifts":
|
||||
var freeGifts []models.CategoryFreeGiftConfig
|
||||
if err := json.Unmarshal([]byte(row.Value), &freeGifts); err == nil {
|
||||
settings.FreeGifts = freeGifts
|
||||
}
|
||||
case "referral_enabled":
|
||||
settings.ReferralEnabled = row.Value == "true"
|
||||
case "referral_amount":
|
||||
@@ -283,6 +290,27 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
return fmt.Errorf("erreur sérialisation promotions: %w", err)
|
||||
}
|
||||
|
||||
if s.FreeGifts == nil {
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{}
|
||||
}
|
||||
for i := range s.FreeGifts {
|
||||
if s.FreeGifts[i].Tiers == nil {
|
||||
s.FreeGifts[i].Tiers = []models.FreeGiftTier{}
|
||||
}
|
||||
if s.FreeGifts[i].Products == nil {
|
||||
s.FreeGifts[i].Products = []models.FreeGiftProductQuantity{}
|
||||
}
|
||||
for j := range s.FreeGifts[i].Products {
|
||||
if s.FreeGifts[i].Products[j].Tiers == nil {
|
||||
s.FreeGifts[i].Products[j].Tiers = []models.FreeGiftTier{}
|
||||
}
|
||||
}
|
||||
}
|
||||
freeGiftsJSON, err := json.Marshal(s.FreeGifts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation free_gifts: %w", err)
|
||||
}
|
||||
|
||||
if s.NowPaymentsCurrencies == nil {
|
||||
s.NowPaymentsCurrencies = []string{}
|
||||
}
|
||||
@@ -324,6 +352,8 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
{"points_reward", string(rewardJSON)},
|
||||
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
|
||||
{"promotions", string(promotionsJSON)},
|
||||
{"free_gifts_enabled", boolStr(s.FreeGiftsEnabled)},
|
||||
{"free_gifts", string(freeGiftsJSON)},
|
||||
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
||||
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
||||
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
||||
|
||||
@@ -379,6 +379,39 @@ func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
|
||||
return total, err
|
||||
}
|
||||
|
||||
// TotalPromoDiscount renvoie le montant total (€) des réductions de prix
|
||||
// accordées par des promotions sur les commandes approuvées, filtré par le
|
||||
// reset "revenus" (même périmètre que TotalRevenue, dont c'est un
|
||||
// sous-indicateur). Basé sur command_items.promo_discount, capturé au moment
|
||||
// de AddToBasket — reflète donc les promos réellement appliquées à l'époque,
|
||||
// pas la config de promotions courante.
|
||||
func (d *Database) TotalPromoDiscount(resetAt time.Time) (float64, error) {
|
||||
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
|
||||
var total float64
|
||||
query := `
|
||||
SELECT COALESCE(SUM(ci.promo_discount), 0)
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
WHERE ` + where
|
||||
err := d.GDB.Raw(query, args...).Scan(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// PromoOrdersCount renvoie le nombre de commandes distinctes (approuvées)
|
||||
// ayant bénéficié d'au moins une réduction de prix promo, filtré par le
|
||||
// reset "revenus".
|
||||
func (d *Database) PromoOrdersCount(resetAt time.Time) (int64, error) {
|
||||
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
|
||||
var count int64
|
||||
query := `
|
||||
SELECT COUNT(DISTINCT ci.command_id)
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
WHERE ci.promo_discount > 0 AND ` + where
|
||||
err := d.GDB.Raw(query, args...).Scan(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ActiveDaysLast30 renvoie le nombre de jours distincts ayant eu au moins une commande sur 30 jours.
|
||||
func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
|
||||
@@ -375,6 +375,16 @@ func ClaimMyReward(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sans produit éligible pour ce pool (ex: catégories de la récompense mal
|
||||
// alignées avec celles du pool), on refuse avant de consommer un point —
|
||||
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
|
||||
// jamais ajouté au panier (récompense perdue silencieusement).
|
||||
if len(itemsToAdd) == 0 {
|
||||
log.Printf("❌ [CLAIM] Aucun produit éligible pour %s (pool=%s)", username, req.PoolKey)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||
return
|
||||
}
|
||||
|
||||
remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "pas de récompense disponible") {
|
||||
|
||||
@@ -204,18 +204,20 @@ func GetAdminStats(c *gin.Context) {
|
||||
|
||||
// Toutes les requêtes sont indépendantes — on les lance en parallèle.
|
||||
var (
|
||||
wdRows []models.WeekdayRow
|
||||
dayRows []models.DayRow
|
||||
dayRevRows []models.DayRevenueRow
|
||||
hourRows []models.HourRow
|
||||
prodRows []models.ProductRow
|
||||
qtyRows []models.QuantityBreakdownRow
|
||||
dailyRows []models.DailyProductRow
|
||||
totalOrders int64
|
||||
totalRevenue float64
|
||||
dailyTotalOrders int64
|
||||
activeDays int64
|
||||
last30Count int64
|
||||
wdRows []models.WeekdayRow
|
||||
dayRows []models.DayRow
|
||||
dayRevRows []models.DayRevenueRow
|
||||
hourRows []models.HourRow
|
||||
prodRows []models.ProductRow
|
||||
qtyRows []models.QuantityBreakdownRow
|
||||
dailyRows []models.DailyProductRow
|
||||
totalOrders int64
|
||||
totalRevenue float64
|
||||
totalPromoDiscount float64
|
||||
promoOrdersCount int64
|
||||
dailyTotalOrders int64
|
||||
activeDays int64
|
||||
last30Count int64
|
||||
)
|
||||
|
||||
eg, _ := errgroup.WithContext(context.Background())
|
||||
@@ -236,6 +238,16 @@ func GetAdminStats(c *gin.Context) {
|
||||
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
totalPromoDiscount, err = database.TotalPromoDiscount(filters.ResetRevenus)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
promoOrdersCount, err = database.PromoOrdersCount(filters.ResetRevenus)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
dailyTotalOrders, err = database.DailyOrdersCount()
|
||||
@@ -430,11 +442,13 @@ func GetAdminStats(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"summary": gin.H{
|
||||
"total_orders": totalOrders,
|
||||
"total_revenue": totalRevenue,
|
||||
"peak_weekday": peakWeekday,
|
||||
"top_product": topProductName,
|
||||
"avg_per_day": avgPerDay,
|
||||
"total_orders": totalOrders,
|
||||
"total_revenue": totalRevenue,
|
||||
"total_promo_discount": totalPromoDiscount,
|
||||
"promo_orders_count": promoOrdersCount,
|
||||
"peak_weekday": peakWeekday,
|
||||
"top_product": topProductName,
|
||||
"avg_per_day": avgPerDay,
|
||||
},
|
||||
"reset_at_commandes": dateFilter(filters.ResetCommandes),
|
||||
"reset_at_revenus": dateFilter(filters.ResetRevenus),
|
||||
|
||||
@@ -19,16 +19,17 @@ func (Command) TableName() string { return "commandes" }
|
||||
|
||||
// CommandItem représente un produit dans une commande
|
||||
type CommandItem struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CommandID int `gorm:"column:command_id" json:"command_id"`
|
||||
Produit string `gorm:"column:produit" json:"produit"`
|
||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
||||
Quantity float64 `gorm:"column:quantite" json:"quantity"`
|
||||
Price float64 `gorm:"column:prix" json:"price"`
|
||||
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CommandID int `gorm:"column:command_id" json:"command_id"`
|
||||
Produit string `gorm:"column:produit" json:"produit"`
|
||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
||||
Quantity float64 `gorm:"column:quantite" json:"quantity"`
|
||||
Price float64 `gorm:"column:prix" json:"price"`
|
||||
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount" json:"promo_discount,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
type CommandLog struct {
|
||||
|
||||
@@ -3,16 +3,17 @@ package models
|
||||
import "time"
|
||||
|
||||
type Panier struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
IsReward bool `json:"is_reward"`
|
||||
RewardPoolKey string `json:"reward_pool_key,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
IsReward bool `json:"is_reward"`
|
||||
RewardPoolKey string `json:"reward_pool_key,omitempty"`
|
||||
PromoDiscount float64 `json:"promo_discount,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -86,6 +86,39 @@ type CategoryPromotionConfig struct {
|
||||
Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
|
||||
}
|
||||
|
||||
// FreeGiftTier définit un seuil d'achat et la quantité offerte associée, du
|
||||
// même produit — plusieurs seuils peuvent coexister pour un même produit
|
||||
// (ex: 10g achetés → 1g offert, 20g achetés → 3g offerts) ; le seuil le plus
|
||||
// élevé atteint par la quantité commandée est retenu (voir ResolveFreeGift).
|
||||
type FreeGiftTier struct {
|
||||
BuyQuantity float64 `json:"buy_quantity"` // quantité à acheter pour déclencher l'offre
|
||||
FreeQuantity float64 `json:"free_quantity"` // quantité offerte du même produit
|
||||
}
|
||||
|
||||
// FreeGiftProductQuantity associe un produit à ses propres seuils
|
||||
// d'achat/offre, pour le cas où une catégorie n'est pas configurée en "tous
|
||||
// les produits" — même logique que PromotionProductQuantity mais pour les
|
||||
// offres quantité achetée/offerte.
|
||||
type FreeGiftProductQuantity struct {
|
||||
ProductID int `json:"product_id"`
|
||||
Tiers []FreeGiftTier `json:"tiers"`
|
||||
}
|
||||
|
||||
// CategoryFreeGiftConfig définit une offre "achetez X, Y offert" (du même
|
||||
// produit) appliquée automatiquement dès que la quantité ajoutée au panier
|
||||
// atteint un seuil configuré — indépendant des points de fidélité et des
|
||||
// promotions (cumulable avec elles).
|
||||
//
|
||||
// Si AllProducts = true, Tiers s'applique uniformément à tous les produits de
|
||||
// la catégorie. Si AllProducts = false, chaque produit sélectionné dans
|
||||
// Products a ses propres seuils (Tiers au niveau catégorie est alors ignoré).
|
||||
type CategoryFreeGiftConfig struct {
|
||||
Category string `json:"category"` // nom de la catégorie
|
||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||
Tiers []FreeGiftTier `json:"tiers"` // seuils uniformes si AllProducts = true
|
||||
Products []FreeGiftProductQuantity `json:"products"` // produits + seuils individuels si AllProducts = false
|
||||
}
|
||||
|
||||
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
||||
type DaySchedule struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -141,6 +174,8 @@ type AppSettings struct {
|
||||
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
||||
PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions
|
||||
Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie
|
||||
FreeGiftsEnabled bool `json:"free_gifts_enabled"` // activer/désactiver les offres "achetez X, Y offert"
|
||||
FreeGifts []CategoryFreeGiftConfig `json:"free_gifts"` // offres quantité achetée/offerte par catégorie
|
||||
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
||||
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
||||
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Persistance des settings (save→reload) ──────────────────────────────────
|
||||
|
||||
func TestUpdateSettings_FreeGiftsRoundTrip(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.FreeGiftsEnabled = true
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||
{
|
||||
Category: "test",
|
||||
AllProducts: false,
|
||||
Products: []models.FreeGiftProductQuantity{
|
||||
{ProductID: 111, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
{BuyQuantity: 20, FreeQuantity: 3},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := testDB.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings: %v", err)
|
||||
}
|
||||
if !loaded.FreeGiftsEnabled {
|
||||
t.Fatal("free_gifts_enabled devrait être true après reload")
|
||||
}
|
||||
if len(loaded.FreeGifts) != 1 {
|
||||
t.Fatalf("free_gifts: got=%d want=1: %+v", len(loaded.FreeGifts), loaded.FreeGifts)
|
||||
}
|
||||
gift := loaded.FreeGifts[0]
|
||||
if gift.Category != "test" || len(gift.Products) != 1 {
|
||||
t.Fatalf("free gift mal persistée: got=%+v", gift)
|
||||
}
|
||||
if len(gift.Products[0].Tiers) != 2 || gift.Products[0].Tiers[1].BuyQuantity != 20 || gift.Products[0].Tiers[1].FreeQuantity != 3 {
|
||||
t.Errorf("tiers mal persistés: got=%+v", gift.Products[0].Tiers)
|
||||
}
|
||||
|
||||
// Désactivation : doit persister à false, pas de résurrection (même
|
||||
// classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil).
|
||||
s.FreeGiftsEnabled = false
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings (désactivation): %v", err)
|
||||
}
|
||||
loaded, err = testDB.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings (désactivation): %v", err)
|
||||
}
|
||||
if loaded.FreeGiftsEnabled {
|
||||
t.Error("free_gifts_enabled devrait rester false après désactivation")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Résolution de la quantité offerte (logique pure) ────────────────────────
|
||||
|
||||
func TestResolveFreeGift_AllProductsAtOrAboveThreshold(t *testing.T) {
|
||||
settings := &models.AppSettings{
|
||||
FreeGiftsEnabled: true,
|
||||
FreeGifts: []models.CategoryFreeGiftConfig{
|
||||
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 1 {
|
||||
t.Errorf("quantité offerte: got=%.2f want=1", got)
|
||||
}
|
||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 15); got != 1 {
|
||||
t.Errorf("au-dessus du seuil, le cadeau reste dû: got=%.2f want=1", got)
|
||||
}
|
||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 9); got != 0 {
|
||||
t.Errorf("sous le seuil, aucun cadeau: got=%.2f want=0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFreeGift_DisabledReturnsZero(t *testing.T) {
|
||||
settings := &models.AppSettings{
|
||||
FreeGiftsEnabled: false,
|
||||
FreeGifts: []models.CategoryFreeGiftConfig{
|
||||
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 0 {
|
||||
t.Errorf("offres désactivées: aucun cadeau attendu: got=%.2f", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFreeGift_PerProductHighestTierApplies(t *testing.T) {
|
||||
settings := &models.AppSettings{
|
||||
FreeGiftsEnabled: true,
|
||||
FreeGifts: []models.CategoryFreeGiftConfig{
|
||||
{
|
||||
Category: "fleurs",
|
||||
AllProducts: false,
|
||||
Products: []models.FreeGiftProductQuantity{
|
||||
{ProductID: 111, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
{BuyQuantity: 20, FreeQuantity: 3},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if got := db.ResolveFreeGift(settings, 111, "fleurs", 10); got != 1 {
|
||||
t.Errorf("seuil 10g: got=%.2f want=1", got)
|
||||
}
|
||||
// 25g dépasse les deux seuils : le plus élevé (20g→3g) doit être retenu,
|
||||
// pas le premier de la liste (10g→1g).
|
||||
if got := db.ResolveFreeGift(settings, 111, "fleurs", 25); got != 3 {
|
||||
t.Errorf("seuil le plus élevé atteint (20g→3g): got=%.2f want=3", got)
|
||||
}
|
||||
// Produit non listé dans cette config : aucun cadeau.
|
||||
if got := db.ResolveFreeGift(settings, 222, "fleurs", 25); got != 0 {
|
||||
t.Errorf("produit non couvert: got=%.2f want=0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Intégration AddToBasket : la quantité livrée inclut le cadeau, au même prix ──
|
||||
|
||||
func TestAddToBasket_AppliesFreeGiftQuantityAtSamePrice(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "freegift_basket_applies")
|
||||
productID := newTestProduct(t, "FreeGiftBasketApplies", 50)
|
||||
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.FreeGiftsEnabled = true
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
}},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
basket, err := testDB.AddToBasket(username, productID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
if basket.Quantity != 11 {
|
||||
t.Errorf("quantité livrée attendue = 10 + 1 offert = 11: got=%.2f", basket.Quantity)
|
||||
}
|
||||
if basket.Price != 10.0 {
|
||||
t.Errorf("le prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", basket.Price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddToBasket_NoFreeGiftBelowThreshold(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "freegift_basket_below")
|
||||
productID := newTestProduct(t, "FreeGiftBasketBelow", 50)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.FreeGiftsEnabled = true
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
}},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
basket, err := testDB.AddToBasket(username, productID, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
if basket.Quantity != 5 {
|
||||
t.Errorf("sous le seuil, aucune quantité offerte: got=%.2f want=5", basket.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
// La quantité réellement décomptée du stock doit inclure le cadeau : un stock
|
||||
// suffisant pour la quantité demandée mais pas pour demandée+offerte doit
|
||||
// faire échouer l'ajout, pas livrer un cadeau partiel.
|
||||
func TestAddToBasket_FreeGiftRejectedWhenStockInsufficientForBonus(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "freegift_basket_stock")
|
||||
productID := newTestProduct(t, "FreeGiftBasketStock", 10) // stock = 10, pile la quantité demandée
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.FreeGiftsEnabled = true
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
}},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.AddToBasket(username, productID, 10); err == nil {
|
||||
t.Fatal("stock=10 ne doit pas suffire pour livrer 10g + 1g offert")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Intégration checkout : le bonus offert est bien décompté du stock ──────
|
||||
//
|
||||
// AddToBasket stocke déjà quantity = demandée + offerte (voir tests
|
||||
// ci-dessus) ; CreateCommandWithAddress ne relit ni ne recalcule cette
|
||||
// quantité — elle est copiée telle quelle dans command_items.quantite et
|
||||
// utilisée telle quelle pour décrémenter products.stock (db_commands.go).
|
||||
// Ces tests vérifient ce chemin de bout en bout, pas juste AddToBasket isolé.
|
||||
|
||||
func TestCheckout_FreeGiftBonusQuantityDecrementsStock(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "freegift_checkout_stock")
|
||||
productID := newTestProduct(t, "FreeGiftCheckoutStock", 50)
|
||||
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.FreeGiftsEnabled = true
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
}},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
|
||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
|
||||
// 50 initial - (10 demandés + 1 offert) = 39, pas 40.
|
||||
if got := productStock(t, productID); got != 39 {
|
||||
t.Errorf("stock après checkout avec cadeau: got=%.2f want=39 (50 - 11)", got)
|
||||
}
|
||||
|
||||
var item struct {
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
}
|
||||
if err := testDB.GDB.Raw(
|
||||
`SELECT quantite, prix FROM command_items WHERE command_id = ? AND product_id = ?`,
|
||||
cmd.ID, productID,
|
||||
).Scan(&item).Error; err != nil {
|
||||
t.Fatalf("lecture command_items: %v", err)
|
||||
}
|
||||
if item.Quantite != 11 {
|
||||
t.Errorf("command_items.quantite doit inclure le cadeau: got=%.2f want=11", item.Quantite)
|
||||
}
|
||||
if item.Prix != 10.0 {
|
||||
t.Errorf("command_items.prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", item.Prix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckout_FreeGiftRollsBackWhenStockInsufficientForBonus(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "freegift_checkout_rollback")
|
||||
productID := newTestProduct(t, "FreeGiftCheckoutRollback", 50)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.FreeGiftsEnabled = true
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
}},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
|
||||
// Le stock chute sous 11 (10 demandés + 1 offert) après l'ajout au panier,
|
||||
// simulant une vente concurrente qui vide le stock entre AddToBasket et
|
||||
// checkout — le checkout doit échouer et ne rien décrémenter.
|
||||
if err := testDB.GDB.Exec(`UPDATE products SET stock = 10 WHERE id = ?`, productID).Error; err != nil {
|
||||
t.Fatalf("réduction stock: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil {
|
||||
t.Fatal("checkout attendu en échec: stock=10 insuffisant pour 10 demandés + 1 offert")
|
||||
}
|
||||
if got := productStock(t, productID); got != 10 {
|
||||
t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=10", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Intégration annulation : le remboursement inclut le bonus offert ───────
|
||||
|
||||
func TestCancelCommandAtomic_RefundsFreeGiftBonusQuantity(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "freegift_cancel_refund")
|
||||
productID := newTestProduct(t, "FreeGiftCancelRefund", 50)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.FreeGiftsEnabled = true
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
}},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
|
||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
if got := productStock(t, productID); got != 39 {
|
||||
t.Fatalf("précondition stock post-checkout: got=%.2f want=39", got)
|
||||
}
|
||||
|
||||
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err != nil {
|
||||
t.Fatalf("CancelCommandAtomic: %v", err)
|
||||
}
|
||||
|
||||
// 39 + 11 (10 demandés + 1 offert) = 50, retour exact au stock initial.
|
||||
if got := productStock(t, productID); got != 50 {
|
||||
t.Errorf("stock après annulation (bonus offert inclus dans le remboursement): got=%.2f want=50", got)
|
||||
}
|
||||
|
||||
// Rejeu : ne doit rembourser qu'une fois.
|
||||
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err == nil {
|
||||
t.Fatal("le second appel sur une commande déjà annulée doit échouer, pas rembourser une seconde fois")
|
||||
}
|
||||
if got := productStock(t, productID); got != 50 {
|
||||
t.Errorf("stock après double annulation: got=%.2f want=50 (un seul remboursement)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cumul avec les promotions de prix ───────────────────────────────────────
|
||||
//
|
||||
// Une offre "achetez X, Y offert" et une promotion de réduction (%) sur le
|
||||
// même produit doivent pouvoir s'appliquer ensemble : la promotion réduit le
|
||||
// prix facturé sur la quantité demandée, le cadeau ajoute de la quantité
|
||||
// livrée sans toucher au prix — les deux mécanismes sont indépendants dans
|
||||
// AddToBasket (voir db_basket.go) mais rien ne garantissait jusqu'ici qu'ils
|
||||
// ne s'écrasent pas mutuellement une fois combinés.
|
||||
func TestAddToBasket_FreeGiftAndPromotionBothApplyTogether(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "freegift_promo_combo")
|
||||
productID := newTestProduct(t, "FreeGiftPromoCombo", 50)
|
||||
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PromotionsEnabled = true
|
||||
s.Promotions = []models.CategoryPromotionConfig{
|
||||
{Category: "test", AllProducts: true, Quantity: 10, DiscountPercent: 20},
|
||||
}
|
||||
s.FreeGiftsEnabled = true
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
}},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
basket, err := testDB.AddToBasket(username, productID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
if basket.Quantity != 11 {
|
||||
t.Errorf("le cadeau doit s'appliquer malgré la promo active: got quantity=%.2f want=11", basket.Quantity)
|
||||
}
|
||||
if basket.Price != 8.0 {
|
||||
t.Errorf("la promo doit s'appliquer malgré le cadeau actif: got price=%.2f want=8.00 (10€ - 20%%)", basket.Price)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Traçage du montant économisé (AddToBasket → command_items) ─────────────
|
||||
//
|
||||
// command_items.promo_discount / baskets.promo_discount capturent le montant
|
||||
// (€) économisé par une promotion de prix au moment de AddToBasket, pour
|
||||
// permettre des statistiques historiques fiables même si la configuration de
|
||||
// promotion change ensuite (voir db_basket.go, commentaire sur promoDiscount).
|
||||
|
||||
func TestAddToBasket_TracksPromoDiscountAmount(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "promo_discount_track")
|
||||
productID := newTestProduct(t, "PromoDiscountTrack", 20)
|
||||
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PromotionsEnabled = true
|
||||
s.Promotions = []models.CategoryPromotionConfig{
|
||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
basket, err := testDB.AddToBasket(username, productID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
if basket.Price != 8.0 {
|
||||
t.Fatalf("précondition prix promo: got=%.2f want=8.00", basket.Price)
|
||||
}
|
||||
if basket.PromoDiscount != 2.0 {
|
||||
t.Errorf("promo_discount doit être l'écart catalogue/promo: got=%.2f want=2.00 (10€-8€)", basket.PromoDiscount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddToBasket_NoPromoDiscountWithoutPromotion(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "promo_discount_none")
|
||||
productID := newTestProduct(t, "PromoDiscountNone", 20)
|
||||
|
||||
basket, err := testDB.AddToBasket(username, productID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
if basket.PromoDiscount != 0 {
|
||||
t.Errorf("sans promo, promo_discount doit rester à 0: got=%.2f", basket.PromoDiscount)
|
||||
}
|
||||
}
|
||||
|
||||
// Deux ajouts successifs du même produit (même ligne panier, is_reward=false)
|
||||
// fusionnent quantité et prix (voir AddToBasket) — promo_discount doit être
|
||||
// cumulé de la même façon, pas remplacé par le dernier ajout.
|
||||
func TestAddToBasket_MergePromoDiscountAccumulatesAcrossAdds(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "promo_discount_merge")
|
||||
productID := newTestProduct(t, "PromoDiscountMerge", 20)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PromotionsEnabled = true
|
||||
s.Promotions = []models.CategoryPromotionConfig{
|
||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
||||
t.Fatalf("AddToBasket (1er ajout): %v", err)
|
||||
}
|
||||
basket, err := testDB.AddToBasket(username, productID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket (2e ajout): %v", err)
|
||||
}
|
||||
if basket.PromoDiscount != 4.0 {
|
||||
t.Errorf("le cumul des deux ajouts doit sommer les remises: got=%.2f want=4.00 (2×2€)", basket.PromoDiscount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckout_PromoDiscountCopiedToCommandItems(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "promo_discount_checkout")
|
||||
productID := newTestProduct(t, "PromoDiscountCheckout", 20)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PromotionsEnabled = true
|
||||
s.Promotions = []models.CategoryPromotionConfig{
|
||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
|
||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
|
||||
var discount float64
|
||||
if err := testDB.GDB.Raw(
|
||||
`SELECT promo_discount FROM command_items WHERE command_id = ? AND product_id = ?`,
|
||||
cmd.ID, productID,
|
||||
).Scan(&discount).Error; err != nil {
|
||||
t.Fatalf("lecture command_items: %v", err)
|
||||
}
|
||||
if discount != 2.0 {
|
||||
t.Errorf("promo_discount doit être copié tel quel au checkout: got=%.2f want=2.00", discount)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats admin : total économisé et nombre de commandes concernées ────────
|
||||
|
||||
func approveTestCommand(t *testing.T, commandID int) {
|
||||
t.Helper()
|
||||
if err := testDB.GDB.Exec(`UPDATE commandes SET status = 'approved' WHERE id = ?`, commandID).Error; err != nil {
|
||||
t.Fatalf("passage en approved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalPromoDiscount_SumsOnlyApprovedOrders(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "stats_promo_discount")
|
||||
productID := newTestProduct(t, "StatsPromoDiscount", 20)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PromotionsEnabled = true
|
||||
s.Promotions = []models.CategoryPromotionConfig{
|
||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
// Commande 1 : avec promo, approuvée → comptée.
|
||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
||||
t.Fatalf("AddToBasket (cmd1): %v", err)
|
||||
}
|
||||
cmd1, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress (cmd1): %v", err)
|
||||
}
|
||||
approveTestCommand(t, cmd1.ID)
|
||||
|
||||
// Commande 2 : avec promo, restée "pending" (statut par défaut du
|
||||
// checkout) → NE DOIT PAS être comptée.
|
||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
||||
t.Fatalf("AddToBasket (cmd2): %v", err)
|
||||
}
|
||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress (cmd2): %v", err)
|
||||
}
|
||||
|
||||
total, err := testDB.TotalPromoDiscount(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalPromoDiscount: %v", err)
|
||||
}
|
||||
if total != 2.0 {
|
||||
t.Errorf("seule la commande approuvée doit compter: got=%.2f want=2.00", total)
|
||||
}
|
||||
|
||||
count, err := testDB.PromoOrdersCount(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("PromoOrdersCount: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("une seule commande approuvée avec promo: got=%d want=1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalPromoDiscount_RespectsResetFilter(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "stats_promo_reset")
|
||||
productID := newTestProduct(t, "StatsPromoReset", 20)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PromotionsEnabled = true
|
||||
s.Promotions = []models.CategoryPromotionConfig{
|
||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
approveTestCommand(t, cmd.ID)
|
||||
|
||||
// Un reset postérieur à la création de la commande doit l'exclure — sert
|
||||
// aussi à vérifier que la jointure command_items/commandes qualifie bien
|
||||
// created_at par l'alias (les deux tables ont une colonne created_at,
|
||||
// donc une clause non qualifiée provoquerait une erreur Postgres
|
||||
// "ambiguous column" plutôt qu'un mauvais résultat).
|
||||
future := time.Now().Add(time.Hour)
|
||||
total, err := testDB.TotalPromoDiscount(future)
|
||||
if err != nil {
|
||||
t.Fatalf("TotalPromoDiscount: %v", err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Errorf("commande antérieure au reset: doit être exclue: got=%.2f want=0", total)
|
||||
}
|
||||
|
||||
count, err := testDB.PromoOrdersCount(future)
|
||||
if err != nil {
|
||||
t.Fatalf("PromoOrdersCount: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("commande antérieure au reset: doit être exclue: got=%d want=0", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Une commande avec plusieurs lignes en promo ne doit compter qu'une fois
|
||||
// dans PromoOrdersCount (COUNT DISTINCT command_id), mais le montant total
|
||||
// doit sommer toutes les lignes.
|
||||
func TestPromoOrdersCount_CountsOrderOnceDespiteMultipleDiscountedItems(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "stats_promo_multi")
|
||||
productA := newTestProduct(t, "StatsPromoMultiA", 20)
|
||||
productB := newTestProduct(t, "StatsPromoMultiB", 20)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PromotionsEnabled = true
|
||||
s.Promotions = []models.CategoryPromotionConfig{
|
||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddToBasket(username, productA, 1); err != nil {
|
||||
t.Fatalf("AddToBasket A: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddToBasket(username, productB, 1); err != nil {
|
||||
t.Fatalf("AddToBasket B: %v", err)
|
||||
}
|
||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
approveTestCommand(t, cmd.ID)
|
||||
|
||||
count, err := testDB.PromoOrdersCount(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("PromoOrdersCount: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("une commande avec 2 lignes en promo doit compter une seule fois: got=%d want=1", count)
|
||||
}
|
||||
|
||||
total, err := testDB.TotalPromoDiscount(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalPromoDiscount: %v", err)
|
||||
}
|
||||
if total != 4.0 {
|
||||
t.Errorf("le montant total doit sommer les deux lignes: got=%.2f want=4.00 (2×2€)", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoOrdersCount_IgnoresOrdersWithoutDiscount(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "stats_promo_zero")
|
||||
productID := newTestProduct(t, "StatsPromoZero", 20)
|
||||
// Aucune promotion configurée : promo_discount reste à 0 pour cette commande.
|
||||
|
||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
approveTestCommand(t, cmd.ID)
|
||||
|
||||
count, err := testDB.PromoOrdersCount(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("PromoOrdersCount: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("aucune commande sans promo ne doit être comptée: got=%d want=0", count)
|
||||
}
|
||||
total, err := testDB.TotalPromoDiscount(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalPromoDiscount: %v", err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Errorf("aucun montant économisé sans promo: got=%.2f want=0", total)
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,51 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// Si les catégories configurées sur la récompense ne correspondent à aucune
|
||||
// catégorie du pool réclamé (erreur de configuration admin : pool assigné à
|
||||
// "test", récompense configurée sur "other"), la liste de produits éligibles
|
||||
// est vide et la réclamation doit échouer avant de consommer un point —
|
||||
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
|
||||
// jamais ajouté au panier (régression couverte : la récompense était
|
||||
// auparavant "consommée" silencieusement sans rien livrer).
|
||||
func TestClaimMyReward_HTTPFlow_RejectsWhenNoEligibleItemsForPoolCategories(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_no_eligible")
|
||||
newTestProduct(t, "RewardHTTPNoEligible", 5)
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
CategoryConfigs: []models.RewardCategoryConfig{
|
||||
{Category: "other", Type: "free_product", AllProducts: true, Quantity: 1},
|
||||
},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 25)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
|
||||
c, rec := claimRewardContext(username, body)
|
||||
handlers.ClaimMyReward(c)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusConflict, rec.Body.String())
|
||||
}
|
||||
|
||||
points, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 0 {
|
||||
t.Errorf("la récompense ne doit PAS être consommée si aucun produit n'est éligible: got redeemed=%d want=0", redeemed["pool_0"])
|
||||
}
|
||||
if points["pool_0"] != 25 {
|
||||
t.Errorf("les points accumulés ne doivent pas être touchés: got=%d want=25", points["pool_0"])
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("aucun produit récompense ne doit être ajouté au panier: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// Une même catégorie peut avoir les deux types de récompense actifs en
|
||||
// parallèle (un lot de produits offerts + un lot de produits à -50%), chacun
|
||||
// avec sa propre sélection de produits et sa propre quantité. Un seul claim
|
||||
|
||||
@@ -13,7 +13,7 @@ BOT2_USERNAME=rezDJDFJSFUltraFast_bot
|
||||
BOT2_WEBHOOK_SECRET=591aVEu1kj3YUVCNWAOU2xGdFNCVWqElzXGi
|
||||
|
||||
# URL publique de la gateway (pour setWebhook Telegram)
|
||||
GATEWAY_URL=https://demo-uber.club
|
||||
GATEWAY_URL=https://uber-demo.club
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=IxGF36s14J0ZNeQCF2Of0APc4kpNd5PlsJ
|
||||
|
||||
@@ -56,6 +56,8 @@ export const logoutAdmin = async (): Promise<void> => {
|
||||
export interface StatsSummary {
|
||||
total_orders: number;
|
||||
total_revenue: number;
|
||||
total_promo_discount: number;
|
||||
promo_orders_count: number;
|
||||
peak_weekday: string;
|
||||
top_product: string;
|
||||
avg_per_day: number;
|
||||
@@ -1139,6 +1141,23 @@ export interface CategoryPromotionConfig {
|
||||
products: PromotionProductQuantity[]; // produits + quantité individuelle si all_products = false
|
||||
}
|
||||
|
||||
export interface FreeGiftTier {
|
||||
buy_quantity: number; // quantité à acheter pour déclencher l'offre
|
||||
free_quantity: number; // quantité offerte du même produit
|
||||
}
|
||||
|
||||
export interface FreeGiftProductQuantity {
|
||||
product_id: number;
|
||||
tiers: FreeGiftTier[]; // seuils propres à ce produit
|
||||
}
|
||||
|
||||
export interface CategoryFreeGiftConfig {
|
||||
category: string;
|
||||
all_products: boolean;
|
||||
tiers: FreeGiftTier[]; // seuils uniformes si all_products = true
|
||||
products: FreeGiftProductQuantity[]; // produits + seuils individuels si all_products = false
|
||||
}
|
||||
|
||||
export interface PointsPool {
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -1240,6 +1259,8 @@ export interface AppSettings {
|
||||
points_reward?: PointsReward | null;
|
||||
promotions_enabled: boolean;
|
||||
promotions: CategoryPromotionConfig[];
|
||||
free_gifts_enabled: boolean;
|
||||
free_gifts: CategoryFreeGiftConfig[];
|
||||
referral_enabled: boolean;
|
||||
delivery_schedule: DeliverySchedule;
|
||||
postal_zones: PostalZone[];
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
|
||||
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, CategoryPromotionConfig, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
||||
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, CategoryPromotionConfig, CategoryFreeGiftConfig, FreeGiftProductQuantity, FreeGiftTier, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
||||
import type { Product } from "../../api/types";
|
||||
import AlertModal from "../../components/ui/AlertModal";
|
||||
import { useAlert } from "../../hooks/useAlert";
|
||||
@@ -1161,13 +1161,22 @@ function PromotionProductPicker({
|
||||
});
|
||||
};
|
||||
|
||||
const updateProductQuantity = (id: number, quantity: number) => {
|
||||
onChange({
|
||||
...catConfig,
|
||||
products: catConfig.products.map((pq) =>
|
||||
pq.product_id === id ? { ...pq, quantity } : pq
|
||||
),
|
||||
});
|
||||
// Chaque produit peut être en promo sur plusieurs paliers de quantité en
|
||||
// même temps (ex: 1g ET 3g) — on ajoute/retire l'entrée {product_id,
|
||||
// quantity} correspondante plutôt que de remplacer une quantité unique.
|
||||
const toggleProductQuantity = (id: number, quantity: number) => {
|
||||
const exists = catConfig.products.some((pq) => pq.product_id === id && pq.quantity === quantity);
|
||||
if (exists) {
|
||||
onChange({
|
||||
...catConfig,
|
||||
products: catConfig.products.filter((pq) => !(pq.product_id === id && pq.quantity === quantity)),
|
||||
});
|
||||
} else {
|
||||
onChange({
|
||||
...catConfig,
|
||||
products: [...catConfig.products, { product_id: id, quantity }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1227,8 +1236,9 @@ function PromotionProductPicker({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Mode "Sélection" : chaque produit choisi a sa propre quantité,
|
||||
via les paliers de prix réels du produit (pas de saisie libre) */}
|
||||
{/* Mode "Sélection" : chaque produit choisi peut être en promo sur
|
||||
plusieurs paliers de quantité à la fois, via les paliers de
|
||||
prix réels du produit (pas de saisie libre) */}
|
||||
{!catConfig.all_products && (
|
||||
<View style={{ gap: spacing.xs }}>
|
||||
{catProducts.length === 0 ? (
|
||||
@@ -1263,13 +1273,16 @@ function PromotionProductPicker({
|
||||
|
||||
{catConfig.products.length > 0 && (
|
||||
<View style={{ gap: spacing.s, marginTop: spacing.xs }}>
|
||||
{catConfig.products.map((pq) => {
|
||||
const prod = catProducts.find((p) => p.id === pq.product_id);
|
||||
{Array.from(new Set(catConfig.products.map((pq) => pq.product_id))).map((productId) => {
|
||||
const prod = catProducts.find((p) => p.id === productId);
|
||||
const tiers = (prod?.prices ?? []).filter((pr) => pr.active_price !== false);
|
||||
const selectedQuantities = catConfig.products
|
||||
.filter((pq) => pq.product_id === productId)
|
||||
.map((pq) => pq.quantity);
|
||||
return (
|
||||
<View key={pq.product_id} style={{ gap: 4 }}>
|
||||
<View key={productId} style={{ gap: 4 }}>
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted }} numberOfLines={1}>
|
||||
{prod?.name ?? `Produit #${pq.product_id}`}
|
||||
{prod?.name ?? `Produit #${productId}`}
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4 }}>
|
||||
{tiers.length === 0 ? (
|
||||
@@ -1277,11 +1290,11 @@ function PromotionProductPicker({
|
||||
Aucun palier de prix actif pour ce produit
|
||||
</Text>
|
||||
) : tiers.map((tier) => {
|
||||
const isSel = pq.quantity === tier.quantity;
|
||||
const isSel = selectedQuantities.includes(tier.quantity);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tier.quantity}
|
||||
onPress={() => updateProductQuantity(pq.product_id, tier.quantity)}
|
||||
onPress={() => toggleProductQuantity(productId, tier.quantity)}
|
||||
style={{
|
||||
paddingHorizontal: spacing.s, paddingVertical: 3,
|
||||
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||
@@ -1507,6 +1520,405 @@ function PromotionsSection({
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Offres "achetez X, Y offert" — quantité supplémentaire du même
|
||||
// produit livrée gratuitement dès qu'un seuil d'achat est atteint,
|
||||
// indépendant des points et des promotions (cumulable avec elles).
|
||||
// Plusieurs seuils peuvent coexister sur un même produit (ex: 10g→+1g,
|
||||
// 20g→+3g) : le seuil le plus élevé atteint par la commande est retenu.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
const FREEGIFT_ACCENT = "#f59e0b";
|
||||
|
||||
function FreeGiftTierListEditor({
|
||||
tiers,
|
||||
onChange,
|
||||
colors,
|
||||
s,
|
||||
}: {
|
||||
tiers: FreeGiftTier[];
|
||||
onChange: (tiers: FreeGiftTier[]) => void;
|
||||
colors: any;
|
||||
s: any;
|
||||
}) {
|
||||
const updateTier = (idx: number, patch: Partial<FreeGiftTier>) => {
|
||||
onChange(tiers.map((t, i) => (i === idx ? { ...t, ...patch } : t)));
|
||||
};
|
||||
const removeTier = (idx: number) => {
|
||||
onChange(tiers.filter((_, i) => i !== idx));
|
||||
};
|
||||
const addTier = () => {
|
||||
onChange([...tiers, { buy_quantity: 0, free_quantity: 0 }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ gap: spacing.xs }}>
|
||||
{tiers.map((t, idx) => (
|
||||
<View key={idx} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
|
||||
<Text style={{ fontSize: 11, color: colors.textMuted }}>Acheté :</Text>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { width: 50, fontSize: 12 }]}
|
||||
keyboardType="decimal-pad"
|
||||
value={t.buy_quantity > 0 ? String(t.buy_quantity) : ""}
|
||||
onChangeText={(v) => {
|
||||
const n = parseFloat(v);
|
||||
updateTier(idx, { buy_quantity: isNaN(n) ? 0 : n });
|
||||
}}
|
||||
placeholder="10"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
/>
|
||||
<Ionicons name="arrow-forward" size={12} color={colors.textMuted} />
|
||||
<Text style={{ fontSize: 11, color: colors.textMuted }}>Offert :</Text>
|
||||
<TextInput
|
||||
style={[s.thresholdInput, { width: 50, fontSize: 12 }]}
|
||||
keyboardType="decimal-pad"
|
||||
value={t.free_quantity > 0 ? String(t.free_quantity) : ""}
|
||||
onChangeText={(v) => {
|
||||
const n = parseFloat(v);
|
||||
updateTier(idx, { free_quantity: isNaN(n) ? 0 : n });
|
||||
}}
|
||||
placeholder="1"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => removeTier(idx)} hitSlop={8}>
|
||||
<Ionicons name="trash-outline" size={15} color={colors.danger ?? "#ef4444"} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
onPress={addTier}
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start", marginTop: 2 }}
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={14} color={FREEGIFT_ACCENT} />
|
||||
<Text style={{ fontSize: 12, color: FREEGIFT_ACCENT, fontWeight: "600" }}>Ajouter un seuil</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FreeGiftProductPicker({
|
||||
catConfig,
|
||||
products,
|
||||
onChange,
|
||||
colors,
|
||||
s,
|
||||
}: {
|
||||
catConfig: CategoryFreeGiftConfig;
|
||||
products: Product[];
|
||||
onChange: (cfg: CategoryFreeGiftConfig) => void;
|
||||
colors: any;
|
||||
s: any;
|
||||
}) {
|
||||
const catProducts = products.filter((p) => p.category === catConfig.category);
|
||||
|
||||
const toggleProduct = (id: number) => {
|
||||
const exists = catConfig.products.some((pq) => pq.product_id === id);
|
||||
if (exists) {
|
||||
onChange({ ...catConfig, products: catConfig.products.filter((pq) => pq.product_id !== id), all_products: false });
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...catConfig,
|
||||
products: [...catConfig.products, { product_id: id, tiers: [{ buy_quantity: 0, free_quantity: 0 }] }],
|
||||
all_products: false,
|
||||
});
|
||||
};
|
||||
|
||||
const updateProductTiers = (id: number, tiers: FreeGiftTier[]) => {
|
||||
onChange({
|
||||
...catConfig,
|
||||
products: catConfig.products.map((pq) => (pq.product_id === id ? { ...pq, tiers } : pq)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
|
||||
{/* Toggle tous / sélection */}
|
||||
<View style={{ flexDirection: "row", gap: spacing.s }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => onChange({ ...catConfig, all_products: true, products: [] })}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.full, borderWidth: 1.5,
|
||||
borderColor: catConfig.all_products ? FREEGIFT_ACCENT : colors.border,
|
||||
backgroundColor: catConfig.all_products ? FREEGIFT_ACCENT + "22" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="checkmark-done-outline" size={13} color={catConfig.all_products ? FREEGIFT_ACCENT : colors.textMuted} />
|
||||
<Text style={{ fontSize: 12, fontWeight: catConfig.all_products ? "700" : "400", color: catConfig.all_products ? FREEGIFT_ACCENT : colors.textMuted }}>
|
||||
Tous ({catProducts.length})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => onChange({ ...catConfig, all_products: false })}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.full, borderWidth: 1.5,
|
||||
borderColor: !catConfig.all_products ? FREEGIFT_ACCENT : colors.border,
|
||||
backgroundColor: !catConfig.all_products ? FREEGIFT_ACCENT + "22" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="list-outline" size={13} color={!catConfig.all_products ? FREEGIFT_ACCENT : colors.textMuted} />
|
||||
<Text style={{ fontSize: 12, fontWeight: !catConfig.all_products ? "700" : "400", color: !catConfig.all_products ? FREEGIFT_ACCENT : colors.textMuted }}>
|
||||
Sélection
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Mode "Tous" : seuils uniformes pour tous les produits de la catégorie */}
|
||||
{catConfig.all_products && (
|
||||
<View>
|
||||
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic", marginBottom: 4 }}>
|
||||
Les quantités achetées doivent correspondre à des paliers de prix existants
|
||||
</Text>
|
||||
<FreeGiftTierListEditor
|
||||
tiers={catConfig.tiers}
|
||||
onChange={(tiers) => onChange({ ...catConfig, tiers })}
|
||||
colors={colors}
|
||||
s={s}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Mode "Sélection" : chaque produit choisi a ses propres seuils */}
|
||||
{!catConfig.all_products && (
|
||||
<View style={{ gap: spacing.xs }}>
|
||||
{catProducts.length === 0 ? (
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
|
||||
Aucun produit dans cette catégorie
|
||||
</Text>
|
||||
) : (
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
||||
{catProducts.map((p) => {
|
||||
const sel = catConfig.products.some((pq) => pq.product_id === p.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={p.id}
|
||||
onPress={() => toggleProduct(p.id)}
|
||||
style={{
|
||||
paddingHorizontal: spacing.s, paddingVertical: 4,
|
||||
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||
borderColor: sel ? FREEGIFT_ACCENT : colors.border,
|
||||
backgroundColor: sel ? FREEGIFT_ACCENT + "22" : "transparent",
|
||||
flexDirection: "row", alignItems: "center", gap: 4,
|
||||
}}
|
||||
>
|
||||
{sel && <Ionicons name="checkmark" size={11} color={FREEGIFT_ACCENT} />}
|
||||
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? FREEGIFT_ACCENT : colors.textMuted }}>
|
||||
{p.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{catConfig.products.length > 0 && (
|
||||
<View style={{ gap: spacing.s, marginTop: spacing.xs }}>
|
||||
{catConfig.products.map((pq) => {
|
||||
const prod = catProducts.find((p) => p.id === pq.product_id);
|
||||
return (
|
||||
<View key={pq.product_id} style={{ gap: 4 }}>
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted }} numberOfLines={1}>
|
||||
{prod?.name ?? `Produit #${pq.product_id}`}
|
||||
</Text>
|
||||
<FreeGiftTierListEditor
|
||||
tiers={pq.tiers}
|
||||
onChange={(tiers) => updateProductTiers(pq.product_id, tiers)}
|
||||
colors={colors}
|
||||
s={s}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FreeGiftsSection({
|
||||
enabled,
|
||||
freeGifts,
|
||||
allCategories,
|
||||
productsByCategory,
|
||||
onToggle,
|
||||
onChangeFreeGifts,
|
||||
colors,
|
||||
s,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
freeGifts: CategoryFreeGiftConfig[];
|
||||
allCategories: Category[];
|
||||
productsByCategory: Record<string, Product[]>;
|
||||
onToggle: (v: boolean) => void;
|
||||
onChangeFreeGifts: (freeGifts: CategoryFreeGiftConfig[]) => void;
|
||||
colors: any;
|
||||
s: any;
|
||||
}) {
|
||||
const getCatConfig = (catName: string): CategoryFreeGiftConfig =>
|
||||
freeGifts.find((g) => g.category === catName) ??
|
||||
{ category: catName, all_products: true, tiers: [], products: [] };
|
||||
|
||||
const isCatSelected = (catName: string) => freeGifts.some((g) => g.category === catName);
|
||||
|
||||
const toggleCategory = (catName: string) => {
|
||||
if (isCatSelected(catName)) {
|
||||
onChangeFreeGifts(freeGifts.filter((g) => g.category !== catName));
|
||||
} else {
|
||||
onChangeFreeGifts([...freeGifts, { category: catName, all_products: true, tiers: [], products: [] }]);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCatConfig = (cfg: CategoryFreeGiftConfig) => {
|
||||
onChangeFreeGifts(freeGifts.map((g) => (g.category === cfg.category ? cfg : g)));
|
||||
};
|
||||
|
||||
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set());
|
||||
const toggleExpanded = (catName: string) => {
|
||||
setExpandedCats((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(catName)) next.delete(catName); else next.add(catName);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const countTiers = (cfg: CategoryFreeGiftConfig) =>
|
||||
cfg.all_products ? cfg.tiers.length : cfg.products.reduce((sum, pq) => sum + pq.tiers.length, 0);
|
||||
|
||||
const badge = (
|
||||
<View style={{
|
||||
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
|
||||
backgroundColor: enabled ? FREEGIFT_ACCENT + "25" : colors.border + "40",
|
||||
borderWidth: 1, borderColor: enabled ? FREEGIFT_ACCENT : colors.border,
|
||||
}}>
|
||||
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? FREEGIFT_ACCENT : colors.textMuted }}>
|
||||
{enabled ? "Activées" : "Désactivées"}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionSection title="Offres quantité offerte" badge={badge} colors={colors} s={s}>
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Offres activées</Text>
|
||||
<Text style={s.rowDesc}>
|
||||
Quantité supplémentaire du même produit livrée gratuitement dès qu'un seuil d'achat est atteint (ex: 10g achetés → 1g offert) — indépendant des points et des promotions, cumulable avec elles.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={onToggle}
|
||||
trackColor={{ false: colors.border, true: FREEGIFT_ACCENT }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{enabled && (
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.l, gap: spacing.l }}>
|
||||
<View>
|
||||
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories concernées</Text>
|
||||
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
|
||||
Sélectionnez une catégorie, puis tous les produits ou une sélection, avec un ou plusieurs seuils achat/offert par produit.
|
||||
</Text>
|
||||
{allCategories.length === 0 ? (
|
||||
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
|
||||
) : (
|
||||
<View style={{ gap: spacing.m }}>
|
||||
{allCategories.map((cat) => {
|
||||
const selected = isCatSelected(cat.name);
|
||||
const expanded = expandedCats.has(cat.name);
|
||||
const catColor = cat.color || FREEGIFT_ACCENT;
|
||||
const cfg = getCatConfig(cat.name);
|
||||
return (
|
||||
<View key={cat.name}>
|
||||
<TouchableOpacity
|
||||
onPress={() => toggleExpanded(cat.name)}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
|
||||
borderRadius: borderRadius.full, borderWidth: 1.5,
|
||||
borderColor: selected ? catColor : colors.border,
|
||||
backgroundColor: selected ? catColor + "22" : "transparent",
|
||||
}}
|
||||
>
|
||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: catColor }} />
|
||||
<Text style={{ fontSize: 13, fontWeight: selected ? "700" : "400", color: selected ? catColor : colors.textMuted }}>
|
||||
{cat.name}{selected && countTiers(cfg) > 0 ? ` · ${countTiers(cfg)} seuil(s)` : ""}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={expanded ? "chevron-down" : "chevron-forward"}
|
||||
size={12}
|
||||
color={selected ? catColor : colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{expanded && (
|
||||
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => toggleCategory(cat.name)}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center", gap: 4,
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: spacing.s, paddingVertical: 4,
|
||||
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||
borderColor: selected ? FREEGIFT_ACCENT : colors.border,
|
||||
backgroundColor: selected ? FREEGIFT_ACCENT + "22" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={selected ? "checkbox" : "square-outline"}
|
||||
size={14}
|
||||
color={selected ? FREEGIFT_ACCENT : colors.textMuted}
|
||||
/>
|
||||
<Ionicons name="gift-outline" size={12} color={selected ? FREEGIFT_ACCENT : colors.textMuted} />
|
||||
<Text style={{ fontSize: 12, fontWeight: selected ? "700" : "400", color: selected ? FREEGIFT_ACCENT : colors.textMuted }}>
|
||||
Offre active sur cette catégorie
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{selected && (
|
||||
<FreeGiftProductPicker
|
||||
catConfig={cfg}
|
||||
products={productsByCategory[cat.name] ?? []}
|
||||
onChange={updateCatConfig}
|
||||
colors={colors}
|
||||
s={s}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Récapitulatif */}
|
||||
{freeGifts.length > 0 && (
|
||||
<View style={{ backgroundColor: FREEGIFT_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: FREEGIFT_ACCENT, padding: spacing.m, gap: 4 }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: "700", color: FREEGIFT_ACCENT }}>Récapitulatif</Text>
|
||||
{freeGifts.map((cfg, idx) => (
|
||||
<Text key={`${cfg.category}-${idx}`} style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||
• {cfg.category} — {cfg.all_products
|
||||
? `tous les produits · ${cfg.tiers.map((t) => `${t.buy_quantity}→+${t.free_quantity}`).join(", ") || "aucun seuil"}`
|
||||
: `${cfg.products.length} produit(s) : ${cfg.products.map((pq) => `#${pq.product_id}[${pq.tiers.map((t) => `${t.buy_quantity}→+${t.free_quantity}`).join(",")}]`).join(", ")}`}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
// Palette violette d'origine de l'application (thème par défaut historique)
|
||||
const ORIGINAL_THEME_COLORS = {
|
||||
admin_color_primary: "#7c3aed",
|
||||
@@ -1564,6 +1976,8 @@ export default function SettingsScreen() {
|
||||
points_reward: null,
|
||||
promotions_enabled: false,
|
||||
promotions: [],
|
||||
free_gifts_enabled: false,
|
||||
free_gifts: [],
|
||||
admin_color_primary: "#7c3aed",
|
||||
admin_color_secondary: "#22d3ee",
|
||||
admin_color_success: "#4ade80",
|
||||
@@ -1648,6 +2062,15 @@ export default function SettingsScreen() {
|
||||
...cfg,
|
||||
products: cfg.products ?? [],
|
||||
})),
|
||||
free_gifts_enabled: s.free_gifts_enabled ?? false,
|
||||
free_gifts: (s.free_gifts ?? []).map((cfg) => ({
|
||||
...cfg,
|
||||
tiers: cfg.tiers ?? [],
|
||||
products: (cfg.products ?? []).map((pq) => ({
|
||||
...pq,
|
||||
tiers: pq.tiers ?? [],
|
||||
})),
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (categoriesRes) {
|
||||
@@ -2275,6 +2698,18 @@ export default function SettingsScreen() {
|
||||
s={s}
|
||||
/>
|
||||
|
||||
{/* Offres "achetez X, Y offert" — quantité offerte du même produit */}
|
||||
<FreeGiftsSection
|
||||
enabled={settings.free_gifts_enabled ?? false}
|
||||
freeGifts={settings.free_gifts ?? []}
|
||||
allCategories={categories}
|
||||
productsByCategory={productsByCategory}
|
||||
onToggle={(v) => setSettings((p) => ({ ...p, free_gifts_enabled: v }))}
|
||||
onChangeFreeGifts={(free_gifts) => setSettings((p) => ({ ...p, free_gifts }))}
|
||||
colors={colors}
|
||||
s={s}
|
||||
/>
|
||||
|
||||
{/* Horaires de livraison */}
|
||||
<DeliveryScheduleSection
|
||||
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
|
||||
|
||||
@@ -1643,6 +1643,20 @@ export default function StatsScreen() {
|
||||
color={CHART_AMBER}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<SummaryCard
|
||||
icon="pricetag-outline"
|
||||
label="Économisé (promos)"
|
||||
value={fmtEuro(s?.total_promo_discount ?? 0)}
|
||||
color={CHART_GREEN}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon="gift-outline"
|
||||
label="Commandes avec promo"
|
||||
value={fmtNum(s?.promo_orders_count ?? 0)}
|
||||
color={CHART_AMBER}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ── Activité du jour ── */}
|
||||
{stats?.daily_detail && (
|
||||
|
||||
@@ -48,7 +48,6 @@ import { useAlert } from "../../hooks/useAlert";
|
||||
|
||||
// --------------------------------------------------
|
||||
// Types
|
||||
// --------------------------------------------------
|
||||
interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
|
||||
@@ -796,7 +796,13 @@ export interface Product {
|
||||
category: string;
|
||||
unit?: string;
|
||||
stock: number;
|
||||
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
||||
prices?: Array<{
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
promo_price?: number | null;
|
||||
promo_percent?: number;
|
||||
}>;
|
||||
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
|
||||
coming_soon?: boolean;
|
||||
}
|
||||
@@ -1440,6 +1446,60 @@ export const cancelCommand = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ UPDATE OWN COMMAND ADDRESS - Corriger l'adresse de sa propre commande
|
||||
* PUT /api/v1/commands/:id/address
|
||||
*/
|
||||
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 de l'adresse",
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -122,6 +122,13 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-price-strike {
|
||||
color: var(--text-muted);
|
||||
text-decoration: line-through;
|
||||
font-size: 0.75em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
color: var(--text-muted);
|
||||
font-size: clamp(0.85rem, 2.5vw, 1rem);
|
||||
|
||||
@@ -28,9 +28,15 @@ interface ProductCardProps {
|
||||
image: string;
|
||||
stock: number;
|
||||
category: string;
|
||||
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
||||
prices?: Array<{
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
promo_price?: number | null;
|
||||
promo_percent?: number;
|
||||
}>;
|
||||
hasVideo?: boolean;
|
||||
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
|
||||
videoUrl?: string;
|
||||
categoryColor?: string;
|
||||
coming_soon?: boolean;
|
||||
}
|
||||
@@ -63,6 +69,11 @@ function ProductCard({
|
||||
const isOutOfStock = stock === 0;
|
||||
const isComingSoon = coming_soon === true;
|
||||
const normalizedCategory = (category || "autre").toLowerCase().trim();
|
||||
const firstPromoPrice =
|
||||
prices?.[0]?.promo_price != null &&
|
||||
prices[0].promo_price < prices[0].price
|
||||
? prices[0].promo_price
|
||||
: null;
|
||||
|
||||
const handleDetailsClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -171,9 +182,20 @@ function ProductCard({
|
||||
<div className="product-info">
|
||||
<h3 className="product-name">{name}</h3>
|
||||
<p className="product-price">
|
||||
{price > 0
|
||||
? `${price.toFixed(2)} €`
|
||||
: "Prix non disponible"}
|
||||
{price > 0 ? (
|
||||
firstPromoPrice !== null ? (
|
||||
<>
|
||||
<span className="product-price-strike">
|
||||
{price.toFixed(2)} €
|
||||
</span>{" "}
|
||||
{firstPromoPrice.toFixed(2)} €
|
||||
</>
|
||||
) : (
|
||||
`${price.toFixed(2)} €`
|
||||
)
|
||||
) : (
|
||||
"Prix non disponible"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,9 +246,11 @@ function ProductCard({
|
||||
key={priceOption.quantity}
|
||||
value={priceOption.quantity}
|
||||
>
|
||||
{priceOption.quantity}
|
||||
{unit} - {priceOption.price.toFixed(2)}{" "}
|
||||
€
|
||||
{priceOption.promo_price != null &&
|
||||
priceOption.promo_price <
|
||||
priceOption.price
|
||||
? `${priceOption.quantity}${unit} - ${priceOption.promo_price.toFixed(2)} € (au lieu de ${priceOption.price.toFixed(2)} €, -${priceOption.promo_percent}%)`
|
||||
: `${priceOption.quantity}${unit} - ${priceOption.price.toFixed(2)} €`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -329,10 +329,10 @@ function ProductDetail() {
|
||||
)}
|
||||
<span style={hasPromo ? { color: "#22c55e" } : undefined}>
|
||||
{selectedPrice.toFixed(2)} €
|
||||
</span>{" "}
|
||||
{selectedGrams &&
|
||||
`pour ${selectedGrams}${product.unit || "g"}`}
|
||||
{hasPromo && ` (-${selectedTier!.promo_percent}%)`}
|
||||
</span>
|
||||
{!hasPromo &&
|
||||
selectedGrams &&
|
||||
` pour ${selectedGrams}${product.unit || "g"}`}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getOrderETA,
|
||||
confirmReception,
|
||||
cancelCommand,
|
||||
updateOwnCommandAddress,
|
||||
isUserAuthenticated,
|
||||
getPublicSettings,
|
||||
} from "../../api/api";
|
||||
@@ -287,6 +288,12 @@ function SuiviLivraison() {
|
||||
useState<CancelCommandResponse | null>(null);
|
||||
const [poolNames, setPoolNames] = useState<string[]>([]);
|
||||
|
||||
const [editingAddressOrder, setEditingAddressOrder] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [newAddress, setNewAddress] = useState("");
|
||||
const [editAddressLoading, setEditAddressLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getPublicSettings().then((s) => setPoolNames(s.pool_names ?? []));
|
||||
}, []);
|
||||
@@ -574,6 +581,53 @@ function SuiviLivraison() {
|
||||
handleCancelOrder(true);
|
||||
};
|
||||
|
||||
const openEditAddressDialog = (orderId: number) => {
|
||||
if (!isUserAuthenticated()) {
|
||||
navigate("/login/client", { replace: true });
|
||||
return;
|
||||
}
|
||||
const order = orders.find((o) => o.id === orderId);
|
||||
setNewAddress(order ? getDeliveryAddress(order) : "");
|
||||
setEditingAddressOrder(orderId);
|
||||
};
|
||||
|
||||
const closeEditAddressDialog = () => {
|
||||
setEditingAddressOrder(null);
|
||||
setNewAddress("");
|
||||
};
|
||||
|
||||
const handleUpdateAddress = async () => {
|
||||
if (!editingAddressOrder || !newAddress.trim()) return;
|
||||
|
||||
try {
|
||||
setEditAddressLoading(true);
|
||||
const response = await updateOwnCommandAddress(
|
||||
editingAddressOrder,
|
||||
newAddress.trim(),
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
showToast("Adresse mise à jour", "success");
|
||||
closeEditAddressDialog();
|
||||
loadOrders();
|
||||
} else {
|
||||
showToast(
|
||||
response.message || "Erreur lors de la mise à jour",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erreur lors de la mise à jour de l'adresse",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setEditAddressLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && orders.length === 0) {
|
||||
return (
|
||||
<>
|
||||
@@ -1143,6 +1197,27 @@ function SuiviLivraison() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="action-buttons">
|
||||
{(statusLow ===
|
||||
"pending" ||
|
||||
statusLow ===
|
||||
"assigned") && (
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() =>
|
||||
openEditAddressDialog(
|
||||
order.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
faMapMarkerAlt
|
||||
}
|
||||
/>{" "}
|
||||
Modifier
|
||||
l'adresse
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-cancel-order"
|
||||
onClick={() =>
|
||||
@@ -1263,6 +1338,73 @@ function SuiviLivraison() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog de modification d'adresse */}
|
||||
{editingAddressOrder !== null && (
|
||||
<div
|
||||
className="confirm-dialog-overlay"
|
||||
onClick={closeEditAddressDialog}
|
||||
>
|
||||
<div
|
||||
className="confirm-dialog"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="confirm-dialog-header">
|
||||
<h3>
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} />{" "}
|
||||
Modifier l'adresse de livraison
|
||||
</h3>
|
||||
</div>
|
||||
<div className="confirm-dialog-body">
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-address">
|
||||
Nouvelle adresse de livraison
|
||||
</label>
|
||||
<textarea
|
||||
id="new-address"
|
||||
value={newAddress}
|
||||
onChange={(e) =>
|
||||
setNewAddress(e.target.value)
|
||||
}
|
||||
placeholder="Adresse complète"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="confirm-dialog-actions">
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={closeEditAddressDialog}
|
||||
disabled={editAddressLoading}
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
<button
|
||||
className="btn-confirm"
|
||||
onClick={handleUpdateAddress}
|
||||
disabled={
|
||||
editAddressLoading || !newAddress.trim()
|
||||
}
|
||||
>
|
||||
{editAddressLoading ? (
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
spin
|
||||
/>{" "}
|
||||
Enregistrement...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faCheck} />{" "}
|
||||
Enregistrer
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog d'annulation */}
|
||||
{showCancelDialog && (
|
||||
<div
|
||||
|
||||
@@ -457,6 +457,29 @@ export const respondToAddressProposal = async (
|
||||
}
|
||||
};
|
||||
|
||||
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> => {
|
||||
|
||||
@@ -47,6 +47,8 @@ interface ProductCardProps {
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
promo_price?: number | null;
|
||||
promo_percent?: number;
|
||||
}>;
|
||||
media?: Array<{ url: string; type: string }>;
|
||||
};
|
||||
@@ -67,6 +69,11 @@ export default function ProductCard({
|
||||
const activePrices =
|
||||
product.prices?.filter((p) => p.active_price !== false) ?? [];
|
||||
const firstPrice = activePrices[0]?.price ?? null;
|
||||
const firstPromoPrice =
|
||||
activePrices[0]?.promo_price != null &&
|
||||
activePrices[0].promo_price < activePrices[0].price
|
||||
? activePrices[0].promo_price
|
||||
: null;
|
||||
|
||||
const imageMedia = product.media?.find((m) => m.type === "image");
|
||||
const videoMedia = product.media?.find((m) => m.type === "video");
|
||||
@@ -198,11 +205,33 @@ export default function ProductCard({
|
||||
>
|
||||
{product.name}
|
||||
</Text>
|
||||
<Text style={[styles.price, { color: colors.success }]}>
|
||||
{firstPrice !== null
|
||||
? `${firstPrice.toFixed(2)} €`
|
||||
: "Prix non disponible"}
|
||||
</Text>
|
||||
{firstPrice !== null ? (
|
||||
firstPromoPrice !== null ? (
|
||||
<View style={styles.priceRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.priceStrike,
|
||||
{ color: colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{firstPrice.toFixed(2)} €
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.price, { color: colors.success }]}
|
||||
>
|
||||
{firstPromoPrice.toFixed(2)} €
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.price, { color: colors.success }]}>
|
||||
{firstPrice.toFixed(2)} €
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text style={[styles.price, { color: colors.success }]}>
|
||||
Prix non disponible
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
@@ -317,14 +346,39 @@ export default function ProductCard({
|
||||
{p.quantity}
|
||||
{product.unit || "g"}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.pickerOptionPrice,
|
||||
{ color: catColor },
|
||||
]}
|
||||
>
|
||||
{p.price.toFixed(2)} €
|
||||
</Text>
|
||||
{p.promo_price != null &&
|
||||
p.promo_price < p.price ? (
|
||||
<View style={styles.pickerPriceRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.priceStrike,
|
||||
{ color: colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{p.price.toFixed(2)} €
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.pickerOptionPrice,
|
||||
{ color: catColor },
|
||||
]}
|
||||
>
|
||||
{p.promo_price.toFixed(2)} €
|
||||
{p.promo_percent
|
||||
? ` (-${p.promo_percent}%)`
|
||||
: ""}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.pickerOptionPrice,
|
||||
{ color: catColor },
|
||||
]}
|
||||
>
|
||||
{p.price.toFixed(2)} €
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Ionicons
|
||||
name="add-circle"
|
||||
@@ -515,6 +569,21 @@ const styles = StyleSheet.create({
|
||||
fontWeight: fontWeight.bold,
|
||||
textAlign: "center",
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
pickerPriceRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
priceStrike: {
|
||||
fontSize: fontSize.md,
|
||||
textDecorationLine: "line-through",
|
||||
},
|
||||
quickAddSection: { padding: spacing.m, borderTopWidth: 1 },
|
||||
quickAddBtn: {
|
||||
width: "100%",
|
||||
|
||||
@@ -128,7 +128,6 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Polling toutes les 15 secondes
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
const interval = setInterval(fetchNotifications, 15000);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
confirmReception,
|
||||
cancelCommand,
|
||||
respondToAddressProposal,
|
||||
updateOwnCommandAddress,
|
||||
formatOrderDate,
|
||||
formatPrice,
|
||||
calculateOrderTotal,
|
||||
@@ -74,6 +75,11 @@ export default function OrderTrackingScreen() {
|
||||
useState<CancelCommandResponse | null>(null);
|
||||
const [penaltyOrderId, setPenaltyOrderId] = useState<number | null>(null);
|
||||
const [penaltiesEnabled, setPenaltiesEnabled] = useState(false);
|
||||
const [editingAddressId, setEditingAddressId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [newAddress, setNewAddress] = useState("");
|
||||
const [editAddressLoading, setEditAddressLoading] = useState(false);
|
||||
const [toastMsg, setToastMsg] = useState("");
|
||||
const [toastType, setToastType] = useState<
|
||||
"success" | "error" | "warning" | "info"
|
||||
@@ -187,6 +193,29 @@ export default function OrderTrackingScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateAddress = async (orderId: number) => {
|
||||
if (!newAddress.trim()) return;
|
||||
setEditAddressLoading(true);
|
||||
try {
|
||||
const res = await updateOwnCommandAddress(
|
||||
orderId,
|
||||
newAddress.trim(),
|
||||
);
|
||||
if (res.success) {
|
||||
showToast("Adresse mise à jour", "success");
|
||||
setEditingAddressId(null);
|
||||
setNewAddress("");
|
||||
fetchOrders();
|
||||
} else {
|
||||
showToast(res.message || "Erreur", "error");
|
||||
}
|
||||
} catch {
|
||||
showToast("Erreur lors de la mise à jour de l'adresse", "error");
|
||||
} finally {
|
||||
setEditAddressLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const styles = useMemo(
|
||||
() =>
|
||||
StyleSheet.create({
|
||||
@@ -407,6 +436,10 @@ export default function OrderTrackingScreen() {
|
||||
"assigned",
|
||||
"en_route",
|
||||
].includes(order.status);
|
||||
const canEditAddress = [
|
||||
"pending",
|
||||
"assigned",
|
||||
].includes(order.status);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -636,6 +669,23 @@ 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"
|
||||
@@ -689,6 +739,52 @@ export default function OrderTrackingScreen() {
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={editingAddressId !== null}
|
||||
onClose={() => {
|
||||
setEditingAddressId(null);
|
||||
setNewAddress("");
|
||||
}}
|
||||
title="Modifier l'adresse de livraison"
|
||||
icon="location-outline"
|
||||
iconColor={colors.accent}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.modalText}>
|
||||
Nouvelle adresse de livraison :
|
||||
</Text>
|
||||
<RNTextInput
|
||||
style={styles.cancelInput}
|
||||
placeholder="Adresse complète"
|
||||
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={cancellingId !== null}
|
||||
onClose={() => {
|
||||
|
||||
@@ -621,11 +621,10 @@ export default function ProductDetailScreen() {
|
||||
hasPromo && { color: "#22c55e" },
|
||||
]}
|
||||
>
|
||||
{selectedPrice.toFixed(2)} €{" "}
|
||||
{selectedGrams &&
|
||||
`pour ${selectedGrams}${product.unit || "g"}`}
|
||||
{hasPromo &&
|
||||
` (-${selectedTier!.promo_percent}%)`}
|
||||
{selectedPrice.toFixed(2)} €
|
||||
{!hasPromo &&
|
||||
selectedGrams &&
|
||||
` pour ${selectedGrams}${product.unit || "g"}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user