chore: add new features
This commit is contained in:
@@ -2150,11 +2150,7 @@ graph LR
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
| Cle Redis | Description | TTL |
|
|
||||||
|-----------|-------------|-----|
|
|
||||||
| `delivery:location:{username}` | Position actuelle du livreur | 2 heures |
|
|
||||||
| `delivery:status:{username}` | Statut + position du livreur | 1 heure |
|
|
||||||
| `geocode:cache:{address_hash}` | Cache geocodage adresse | 7 jours |
|
|
||||||
| `command:destination:{command_id}` | Coordonnees destination | 4 heures |
|
| `command:destination:{command_id}` | Coordonnees destination | 4 heures |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -2301,11 +2297,7 @@ Le systeme bascule automatiquement sur le calcul local:
|
|||||||
- Estime l'ETA avec une vitesse moyenne de 30 km/h
|
- Estime l'ETA avec une vitesse moyenne de 30 km/h
|
||||||
- Un flag `fallback_used: true` est ajoute a la reponse
|
- Un flag `fallback_used: true` est ajoute a la reponse
|
||||||
|
|
||||||
#### Position Livreur Obsolete
|
|
||||||
|
|
||||||
- Les positions de plus de 2 heures sont considerees obsoletes
|
|
||||||
- Le livreur doit mettre a jour sa position pour recevoir des commandes
|
|
||||||
- Un warning est affiche dans l'interface admin
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1000,3 +1000,94 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
|
|||||||
|
|
||||||
return totalPoints, nil
|
return totalPoints, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApproveDeliveryAtomicByStaff - Confirmation de réception par admin ou cabine à la place du client
|
||||||
|
func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, error) {
|
||||||
|
log.Printf("🔒 [ApproveAtomicStaff] START - cmd=%d, staff=%s", commandID, staffUsername)
|
||||||
|
|
||||||
|
tx, err := d.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("erreur transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var currentStatus, clientUsername, livreurAssign string
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
SELECT status, username, COALESCE(livreur_assign, '')
|
||||||
|
FROM commandes
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE
|
||||||
|
`, commandID).Scan(¤tStatus, &clientUsername, &livreurAssign)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, "", fmt.Errorf("commande non trouvée")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("erreur lecture commande: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentStatus != "livre" {
|
||||||
|
return 0, "", fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", currentStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := tx.Exec(`
|
||||||
|
UPDATE commandes
|
||||||
|
SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1 AND status = 'livre'
|
||||||
|
`, commandID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
return 0, "", fmt.Errorf("commande déjà approuvée ou modifiée")
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPoints, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, clientUsername)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("erreur attribution points: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE clients
|
||||||
|
SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE username = $1
|
||||||
|
`, clientUsername)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ [ApproveAtomicStaff] Erreur incrémentation compteur: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
||||||
|
`, commandID, "approved",
|
||||||
|
fmt.Sprintf("Réception confirmée par %s au nom du client %s - %d points attribués", staffUsername, clientUsername, totalPoints),
|
||||||
|
staffUsername)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ [ApproveAtomicStaff] Erreur log: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, "", fmt.Errorf("erreur commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("🎉 [ApproveAtomicStaff] SUCCÈS - cmd=%d approuvée par %s, %d points → client %s",
|
||||||
|
commandID, staffUsername, totalPoints, clientUsername)
|
||||||
|
|
||||||
|
if livreurAssign != "" {
|
||||||
|
go func() {
|
||||||
|
if err := d.CompleteDeliveryAndProcessNext(livreurAssign, commandID); err != nil {
|
||||||
|
log.Printf("⚠️ [ApproveAtomicStaff] Erreur queue: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
Redis.Del(RedisCtx, fmt.Sprintf("command:%d", commandID))
|
||||||
|
Redis.Del(RedisCtx, fmt.Sprintf("client:%s", clientUsername))
|
||||||
|
Redis.Del(RedisCtx, fmt.Sprintf("client:%s:commands", clientUsername))
|
||||||
|
}()
|
||||||
|
|
||||||
|
return totalPoints, clientUsername, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ func (db *Database) CreateProduct(product interface{}) error {
|
|||||||
log.Printf("📦 [DB CreateProduct] Category: %s", p.GetCategory())
|
log.Printf("📦 [DB CreateProduct] Category: %s", p.GetCategory())
|
||||||
log.Printf("📦 [DB CreateProduct] Description: %s", p.GetDescription())
|
log.Printf("📦 [DB CreateProduct] Description: %s", p.GetDescription())
|
||||||
log.Printf("📦 [DB CreateProduct] Stock: %.2f", p.GetStock())
|
log.Printf("📦 [DB CreateProduct] Stock: %.2f", p.GetStock())
|
||||||
|
log.Printf("📦 [DB CreateProduct] Unit: %s", p.GetUnit())
|
||||||
log.Printf("📦 [DB CreateProduct] Nombre de prix: %d", len(p.GetPrices()))
|
log.Printf("📦 [DB CreateProduct] Nombre de prix: %d", len(p.GetPrices()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ type ProfileResponse struct {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
clientTokenDuration = 5 * time.Hour
|
clientTokenDuration = 5 * time.Hour
|
||||||
adminTokenDuration = 2 * time.Hour
|
adminTokenDuration = 10 * time.Hour
|
||||||
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET")) // ✅ Pour clients
|
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET")) // ✅ Pour clients
|
||||||
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET")) // ✅ Pour admin/cabine/livreur
|
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET")) // ✅ Pour admin/cabine/livreur
|
||||||
)
|
)
|
||||||
@@ -287,7 +287,7 @@ func LoginClient(c *gin.Context) {
|
|||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
client, err := database.GetClientByUsername(req.Username)
|
client, err := database.GetClientByUsername(req.Username)
|
||||||
if err != nil {
|
if err != nil || client == nil {
|
||||||
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
|
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||||
return
|
return
|
||||||
@@ -327,13 +327,13 @@ func LoginClient(c *gin.Context) {
|
|||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: int(clientTokenDuration.Seconds()),
|
ExpiresIn: int(clientTokenDuration.Seconds()),
|
||||||
User: gin.H{
|
User: gin.H{
|
||||||
"id": client.ID,
|
"id": client.ID,
|
||||||
"username": client.Username,
|
"username": client.Username,
|
||||||
"nom": client.Nom,
|
"nom": client.Nom,
|
||||||
"prenom": client.Prenom,
|
"prenom": client.Prenom,
|
||||||
"telephone": client.Telephone,
|
"telephone": client.Telephone,
|
||||||
"role": "client",
|
"role": "client",
|
||||||
"session_id": sessionID,
|
"session_id": sessionID,
|
||||||
"must_change_password": client.MustChangePassword,
|
"must_change_password": client.MustChangePassword,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -305,6 +305,51 @@ func ApproveDelivery(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIRMATION RÉCEPTION PAR STAFF (ADMIN / CABINE)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func StaffApproveDelivery(c *gin.Context) {
|
||||||
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
|
role := c.GetString("role")
|
||||||
|
if role != "admin" && role != "cabine" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux admins et à la cabine"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
staffUsername, err := safeGetUsername(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commandID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || commandID <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ [STAFF_APPROVE] %s (%s) confirme réception cmd %d", staffUsername, role, commandID)
|
||||||
|
|
||||||
|
totalPoints, clientUsername, err := database.ApproveDeliveryAtomicByStaff(commandID, staffUsername)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ [STAFF_APPROVE] Erreur: %v", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible de confirmer la réception: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ [STAFF_APPROVE] %d points attribués au client %s", totalPoints, clientUsername)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Réception confirmée",
|
||||||
|
"command_id": commandID,
|
||||||
|
"client_username": clientUsername,
|
||||||
|
"points_earned": totalPoints,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// APPROBATION PAR ADMIN
|
// APPROBATION PAR ADMIN
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -464,37 +509,49 @@ func GetAvailableDeliveryPersons(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AssignDeliveryPerson assigne manuellement un livreur à une commande
|
// AssignDeliveryPerson assigne manuellement un livreur à une commande
|
||||||
// POST /api/v1/admin/commands/:id/assign
|
// Admin: POST /api/v2/admin/protected/delivery-persons/:username/assign/:command_id
|
||||||
|
// Cabine: POST /api/v1/cabine/commands/:id/assign (body: {"livreur_username": "..."})
|
||||||
func AssignDeliveryPerson(c *gin.Context) {
|
func AssignDeliveryPerson(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
// ✅ SÉCURITÉ: Admin seulement
|
// ✅ SÉCURITÉ: Admin ou Cabine
|
||||||
if c.GetString("role") != "admin" {
|
role := c.GetString("role")
|
||||||
|
if role != "admin" && role != "cabine" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
commandID, err := strconv.Atoi(c.Param("id"))
|
// Support deux formats de route: :command_id (admin) ou :id (cabine)
|
||||||
if err != nil {
|
commandIDStr := c.Param("command_id")
|
||||||
|
if commandIDStr == "" {
|
||||||
|
commandIDStr = c.Param("id")
|
||||||
|
}
|
||||||
|
commandID, err := strconv.Atoi(commandIDStr)
|
||||||
|
if err != nil || commandID == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
// Livreur depuis URL param (admin) ou body JSON (cabine)
|
||||||
LivreurUsername string `json:"livreur_username" binding:"required"`
|
livreurUsername := c.Param("username")
|
||||||
}
|
if livreurUsername == "" {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req struct {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
LivreurUsername string `json:"livreur_username" binding:"required"`
|
||||||
"error": "Données invalides",
|
}
|
||||||
"details": err.Error(),
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
})
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
return
|
"error": "Données invalides",
|
||||||
|
"details": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
livreurUsername = req.LivreurUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("👤 [ASSIGN] Assignation cmd %d à livreur %s", commandID, req.LivreurUsername)
|
log.Printf("👤 [ASSIGN] Assignation cmd %d à livreur %s", commandID, livreurUsername)
|
||||||
|
|
||||||
adminUsername, _ := c.Get("username")
|
staffUsername, _ := c.Get("username")
|
||||||
if err := database.AssignDeliveryPerson(commandID, req.LivreurUsername); err != nil {
|
if err := database.AssignDeliveryPerson(commandID, livreurUsername); err != nil {
|
||||||
log.Printf("❌ [ASSIGN] Erreur: %v", err)
|
log.Printf("❌ [ASSIGN] Erreur: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur assignation livreur",
|
"error": "Erreur assignation livreur",
|
||||||
@@ -504,17 +561,17 @@ func AssignDeliveryPerson(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
database.AddCommandLog(commandID, "support",
|
database.AddCommandLog(commandID, "support",
|
||||||
fmt.Sprintf("Livreur '%s' assigné manuellement par %s", req.LivreurUsername, adminUsername),
|
fmt.Sprintf("Livreur '%s' assigné manuellement par %s", livreurUsername, staffUsername),
|
||||||
adminUsername.(string))
|
staffUsername.(string))
|
||||||
|
|
||||||
log.Printf("✅ [ASSIGN] Commande %d assignée à %s", commandID, req.LivreurUsername)
|
log.Printf("✅ [ASSIGN] Commande %d assignée à %s", commandID, livreurUsername)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Livreur assigné avec succès",
|
"message": "Livreur assigné avec succès",
|
||||||
"command_id": commandID,
|
"command_id": commandID,
|
||||||
"livreur": req.LivreurUsername,
|
"livreur": livreurUsername,
|
||||||
"assigned_by": adminUsername,
|
"assigned_by": staffUsername,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
@@ -21,6 +22,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Forcer la timezone Europe/Paris (UTC+1/+2)
|
||||||
|
if loc, err := time.LoadLocation("Europe/Paris"); err == nil {
|
||||||
|
time.Local = loc
|
||||||
|
} else {
|
||||||
|
log.Printf("⚠️ Impossible de charger la timezone Europe/Paris: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Chargement des variables d'environnement
|
// Chargement des variables d'environnement
|
||||||
if err := godotenv.Load(); err != nil {
|
if err := godotenv.Load(); err != nil {
|
||||||
log.Println("⚠️ Aucun fichier .env trouvé, utilisation des valeurs par défaut.")
|
log.Println("⚠️ Aucun fichier .env trouvé, utilisation des valeurs par défaut.")
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
adminGroupV2.GET("/orders/:id", handlers.GetCommandByID)
|
adminGroupV2.GET("/orders/:id", handlers.GetCommandByID)
|
||||||
adminGroupV2.PUT("/orders/:id/address", handlers.UpdateCommandAddress)
|
adminGroupV2.PUT("/orders/:id/address", handlers.UpdateCommandAddress)
|
||||||
adminGroupV2.POST("/orders/:id/force-validate", handlers.ValidateDelivery)
|
adminGroupV2.POST("/orders/:id/force-validate", handlers.ValidateDelivery)
|
||||||
|
adminGroupV2.POST("/orders/:id/confirm-reception", handlers.StaffApproveDelivery)
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ⭐ AUTO-ASSIGNATION GPS - ROUTES CRITIQUES
|
// ⭐ AUTO-ASSIGNATION GPS - ROUTES CRITIQUES
|
||||||
@@ -234,6 +235,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
cabineGroupV1.Use(middleware.CabineMiddleware)
|
cabineGroupV1.Use(middleware.CabineMiddleware)
|
||||||
{
|
{
|
||||||
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
|
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
|
||||||
|
cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery)
|
||||||
|
cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson)
|
||||||
cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus)
|
cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus)
|
||||||
cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
|
cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
|
||||||
cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen)
|
cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen)
|
||||||
|
|||||||
@@ -159,6 +159,18 @@ export const validateCommand = async (commandId: number) => {
|
|||||||
return { success: true, message: data.message };
|
return { success: true, message: data.message };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const confirmReceptionAdmin = async (commandId: number) => {
|
||||||
|
const { data } = await apiClient.post(
|
||||||
|
`${V2}/admin/protected/orders/${commandId}/confirm-reception`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: data.message,
|
||||||
|
points_earned: data.points_earned,
|
||||||
|
client_username: data.client_username,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// LIVREURS
|
// LIVREURS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ export const updateItemStatus = async (itemId: number, status: string) => {
|
|||||||
return { success: true, message: data.message };
|
return { success: true, message: data.message };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const confirmReceptionCabine = async (commandId: number) => {
|
||||||
|
const { data } = await apiClient.post(
|
||||||
|
`${API}/commands/${commandId}/confirm-reception`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: data.message,
|
||||||
|
points_earned: data.points_earned,
|
||||||
|
client_username: data.client_username,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// PENALITES
|
// PENALITES
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -105,6 +117,24 @@ export const deleteCommand = async (commandId: number) => {
|
|||||||
return { success: true, message: data.message };
|
return { success: true, message: data.message };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCabineLivreursList = async (): Promise<
|
||||||
|
{ id: number; username: string }[]
|
||||||
|
> => {
|
||||||
|
const { data } = await apiClient.get(`${API}/all/deliveryman`);
|
||||||
|
return data.users || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assignDeliveryPersonByCabine = async (
|
||||||
|
commandId: number,
|
||||||
|
livreurUsername: string,
|
||||||
|
) => {
|
||||||
|
const { data } = await apiClient.post(
|
||||||
|
`${API}/commands/${commandId}/assign`,
|
||||||
|
{ livreur_username: livreurUsername },
|
||||||
|
);
|
||||||
|
return { success: true, message: data.message };
|
||||||
|
};
|
||||||
|
|
||||||
export const getDeliverymanLocationForCommand = async (commandId: number) => {
|
export const getDeliverymanLocationForCommand = async (commandId: number) => {
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.get(
|
const { data } = await apiClient.get(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getCommandItems,
|
getCommandItems,
|
||||||
updateCommandStatus,
|
updateCommandStatus,
|
||||||
validateCommand,
|
validateCommand,
|
||||||
|
confirmReceptionAdmin,
|
||||||
getDeliveryPersonDetails,
|
getDeliveryPersonDetails,
|
||||||
} from "../../api/api_admin";
|
} from "../../api/api_admin";
|
||||||
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
|
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
|
||||||
@@ -75,6 +76,16 @@ export default function OrderDetailScreen() {
|
|||||||
load();
|
load();
|
||||||
}, [orderId]);
|
}, [orderId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getCommandByID(orderId);
|
||||||
|
setCommand(res.command);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 20000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [orderId]);
|
||||||
|
|
||||||
const loadLivreurRoute = async (
|
const loadLivreurRoute = async (
|
||||||
livreurUsername: string,
|
livreurUsername: string,
|
||||||
deliveryAddress: string,
|
deliveryAddress: string,
|
||||||
@@ -156,6 +167,20 @@ export default function OrderDetailScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmReception = async () => {
|
||||||
|
try {
|
||||||
|
const res = await confirmReceptionAdmin(orderId);
|
||||||
|
showSuccess(
|
||||||
|
"Réception confirmée",
|
||||||
|
`${res.points_earned} point(s) attribués au client ${res.client_username}`,
|
||||||
|
);
|
||||||
|
const updated = await getCommandByID(orderId);
|
||||||
|
setCommand(updated.command);
|
||||||
|
} catch (e: any) {
|
||||||
|
showError("Erreur", e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const styles = useMemo(
|
const styles = useMemo(
|
||||||
() =>
|
() =>
|
||||||
StyleSheet.create({
|
StyleSheet.create({
|
||||||
@@ -622,6 +647,15 @@ export default function OrderDetailScreen() {
|
|||||||
style={{ marginTop: spacing.s }}
|
style={{ marginTop: spacing.s }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{command.status === "livre" && (
|
||||||
|
<Button
|
||||||
|
title="Confirmer la réception"
|
||||||
|
onPress={handleConfirmReception}
|
||||||
|
variant="primary"
|
||||||
|
fullWidth
|
||||||
|
style={{ marginTop: spacing.s }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<AlertModal
|
<AlertModal
|
||||||
visible={alert.visible}
|
visible={alert.visible}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export default function OrdersScreen() {
|
|||||||
{new Date(item.created_at).toLocaleString("fr-FR")}
|
{new Date(item.created_at).toLocaleString("fr-FR")}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{item.status === "pending" && (
|
{!["approved", "cancelled"].includes(item.status) && (
|
||||||
<Button
|
<Button
|
||||||
title="Assigner"
|
title="Assigner"
|
||||||
onPress={() => openAssignModal(item.id)}
|
onPress={() => openAssignModal(item.id)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
FlatList,
|
FlatList,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||||
@@ -14,6 +15,9 @@ import { getAllCommands } from "../../api/api_admin";
|
|||||||
import {
|
import {
|
||||||
getCommandItems,
|
getCommandItems,
|
||||||
deleteCommand,
|
deleteCommand,
|
||||||
|
confirmReceptionCabine,
|
||||||
|
getCabineLivreursList,
|
||||||
|
assignDeliveryPersonByCabine,
|
||||||
} from "../../api/api_cabine";
|
} from "../../api/api_cabine";
|
||||||
import type { CommandResponse } from "../../api/types";
|
import type { CommandResponse } from "../../api/types";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
@@ -61,6 +65,11 @@ export default function OrdersScreen() {
|
|||||||
commandInfo: null,
|
commandInfo: null,
|
||||||
clientInfo: null,
|
clientInfo: null,
|
||||||
});
|
});
|
||||||
|
const [assignModal, setAssignModal] = useState<{
|
||||||
|
visible: boolean;
|
||||||
|
commandId: number | null;
|
||||||
|
}>({ visible: false, commandId: null });
|
||||||
|
const [livreurs, setLivreurs] = useState<{ id: number; username: string }[]>([]);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -81,6 +90,13 @@ export default function OrdersScreen() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadData();
|
||||||
|
}, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
await loadData();
|
await loadData();
|
||||||
@@ -103,6 +119,22 @@ export default function OrdersScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleConfirmReception = (commandId: number) => {
|
||||||
|
showConfirm(
|
||||||
|
"Confirmer la réception",
|
||||||
|
`Confirmer la réception de la commande #${commandId} au nom du client ?`,
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await confirmReceptionCabine(commandId);
|
||||||
|
await loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
showError("Erreur", e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Confirmer",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = (commandId: number) => {
|
const handleDelete = (commandId: number) => {
|
||||||
showConfirm(
|
showConfirm(
|
||||||
"Supprimer",
|
"Supprimer",
|
||||||
@@ -128,6 +160,27 @@ export default function OrdersScreen() {
|
|||||||
clientInfo: null,
|
clientInfo: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openAssignModal = async (commandId: number) => {
|
||||||
|
try {
|
||||||
|
const list = await getCabineLivreursList();
|
||||||
|
setLivreurs(list);
|
||||||
|
setAssignModal({ visible: true, commandId });
|
||||||
|
} catch {
|
||||||
|
showError("Erreur", "Impossible de charger les livreurs");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssign = async (livreurUsername: string) => {
|
||||||
|
if (!assignModal.commandId) return;
|
||||||
|
try {
|
||||||
|
await assignDeliveryPersonByCabine(assignModal.commandId, livreurUsername);
|
||||||
|
setAssignModal({ visible: false, commandId: null });
|
||||||
|
await loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
showError("Erreur", e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const styles = useMemo(
|
const styles = useMemo(
|
||||||
() =>
|
() =>
|
||||||
StyleSheet.create({
|
StyleSheet.create({
|
||||||
@@ -271,6 +324,16 @@ export default function OrdersScreen() {
|
|||||||
fontSize: fontSize.xs,
|
fontSize: fontSize.xs,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
|
livreurItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.m,
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
marginBottom: spacing.s,
|
||||||
|
gap: spacing.m,
|
||||||
|
},
|
||||||
|
livreurName: { color: colors.textWhite, fontSize: fontSize.md },
|
||||||
}),
|
}),
|
||||||
[colors],
|
[colors],
|
||||||
);
|
);
|
||||||
@@ -293,6 +356,20 @@ export default function OrdersScreen() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
title="Assigner"
|
||||||
|
onPress={() => openAssignModal(item.id)}
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
|
{item.status === "livre" && (
|
||||||
|
<Button
|
||||||
|
title="Confirmer réception"
|
||||||
|
onPress={() => handleConfirmReception(item.id)}
|
||||||
|
size="sm"
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
title="Supprimer"
|
title="Supprimer"
|
||||||
onPress={() => handleDelete(item.id)}
|
onPress={() => handleDelete(item.id)}
|
||||||
@@ -461,6 +538,33 @@ export default function OrdersScreen() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={assignModal.visible}
|
||||||
|
onClose={() =>
|
||||||
|
setAssignModal({ visible: false, commandId: null })
|
||||||
|
}
|
||||||
|
title={`Assigner commande #${assignModal.commandId}`}
|
||||||
|
icon="bicycle-outline"
|
||||||
|
>
|
||||||
|
{livreurs.map((l) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={l.username}
|
||||||
|
style={styles.livreurItem}
|
||||||
|
onPress={() => handleAssign(l.username)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="person-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.accent}
|
||||||
|
/>
|
||||||
|
<Text style={styles.livreurName}>{l.username}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
{livreurs.length === 0 && (
|
||||||
|
<Text style={styles.empty}>Aucun livreur disponible</Text>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<AlertModal
|
<AlertModal
|
||||||
visible={alert.visible}
|
visible={alert.visible}
|
||||||
type={alert.type}
|
type={alert.type}
|
||||||
|
|||||||
@@ -271,6 +271,12 @@ export default function DashboardScreen() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadData();
|
||||||
|
}, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
// Quand GPS devient disponible, rejouer la route en attente
|
// Quand GPS devient disponible, rejouer la route en attente
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 848 KiB |
@@ -6,6 +6,13 @@
|
|||||||
// ✅ sessionStorage (pas localStorage)
|
// ✅ sessionStorage (pas localStorage)
|
||||||
|
|
||||||
const API_URL = "https://uber-stup.club/api/v1";
|
const API_URL = "https://uber-stup.club/api/v1";
|
||||||
|
const BACKEND_URL = "https://uber-stup.club";
|
||||||
|
|
||||||
|
export function getMediaUrl(url: string): string {
|
||||||
|
if (!url) return "";
|
||||||
|
if (url.startsWith("http")) return url;
|
||||||
|
return `${BACKEND_URL}${url}`;
|
||||||
|
}
|
||||||
import type {
|
import type {
|
||||||
ConfirmReceptionResponse,
|
ConfirmReceptionResponse,
|
||||||
CheckoutCartResponse,
|
CheckoutCartResponse,
|
||||||
@@ -291,14 +298,21 @@ export const changePassword = async (
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: data.error || data.message || "Erreur lors du changement de mot de passe",
|
message:
|
||||||
|
data.error ||
|
||||||
|
data.message ||
|
||||||
|
"Erreur lors du changement de mot de passe",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { success: true, message: data.message || "Mot de passe mis à jour" };
|
return {
|
||||||
|
success: true,
|
||||||
|
message: data.message || "Mot de passe mis à jour",
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : "Erreur de connexion",
|
message:
|
||||||
|
error instanceof Error ? error.message : "Erreur de connexion",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -763,6 +777,7 @@ export interface Product {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
unit?: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
prices?: Array<{ quantity: number; price: number }>;
|
prices?: Array<{ quantity: number; price: number }>;
|
||||||
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
|
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ export interface Product {
|
|||||||
description?: string;
|
description?: string;
|
||||||
category: string;
|
category: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
|
unit?: string;
|
||||||
prices?: ProductPrice[];
|
prices?: ProductPrice[];
|
||||||
media?: Array<{
|
media?: Array<{
|
||||||
// ✅ CHANGÉ
|
// ✅ CHANGÉ
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function ProductCard({
|
|||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
price,
|
price,
|
||||||
|
unit = "g",
|
||||||
image,
|
image,
|
||||||
stock,
|
stock,
|
||||||
category = "autre",
|
category = "autre",
|
||||||
@@ -180,7 +181,7 @@ function ProductCard({
|
|||||||
key={priceOption.quantity}
|
key={priceOption.quantity}
|
||||||
value={priceOption.quantity}
|
value={priceOption.quantity}
|
||||||
>
|
>
|
||||||
{priceOption.quantity}g -{" "}
|
{priceOption.quantity}{unit} -{" "}
|
||||||
{priceOption.price.toFixed(2)} €
|
{priceOption.price.toFixed(2)} €
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Product, ProductPrice } from "../api/api_types";
|
import type { Product, ProductPrice } from "../api/api_types";
|
||||||
|
import { getMediaUrl } from "../api/api";
|
||||||
import "./ProductModal.css";
|
import "./ProductModal.css";
|
||||||
|
|
||||||
interface ProductDetailsModalProps {
|
interface ProductDetailsModalProps {
|
||||||
@@ -114,14 +115,14 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
|
|||||||
<div className="media-viewer">
|
<div className="media-viewer">
|
||||||
{currentMedia?.type === "image" ? (
|
{currentMedia?.type === "image" ? (
|
||||||
<img
|
<img
|
||||||
src={`${currentMedia.url}`}
|
src={getMediaUrl(currentMedia.url)}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
className="media-display"
|
className="media-display"
|
||||||
/>
|
/>
|
||||||
) : currentMedia?.type === "video" ? (
|
) : currentMedia?.type === "video" ? (
|
||||||
<div className="video-container">
|
<div className="video-container">
|
||||||
<video
|
<video
|
||||||
src={`${currentMedia.url}`}
|
src={getMediaUrl(currentMedia.url)}
|
||||||
controls
|
controls
|
||||||
className="media-display"
|
className="media-display"
|
||||||
>
|
>
|
||||||
@@ -179,7 +180,7 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
|
|||||||
>
|
>
|
||||||
{media.type === "image" ? (
|
{media.type === "image" ? (
|
||||||
<img
|
<img
|
||||||
src={`{product.media[0].url}${media.url}`}
|
src={getMediaUrl(media.url)}
|
||||||
alt={`Media ${index + 1}`}
|
alt={`Media ${index + 1}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import ProductCard from "../../components/ProductCard";
|
import ProductCard from "../../components/ProductCard";
|
||||||
import Navbar from "../../components/Navbar";
|
import Navbar from "../../components/Navbar";
|
||||||
import { getAllProducts, getProductsByCategory } from "../../api/api";
|
import { getAllProducts, getProductsByCategory, getMediaUrl } from "../../api/api";
|
||||||
import type { Product } from "../../api/api";
|
import type { Product } from "../../api/api";
|
||||||
import "./UserAccueil.css";
|
import "./UserAccueil.css";
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ function UserAccueil() {
|
|||||||
(mediaItem) => mediaItem && mediaItem.type === "video",
|
(mediaItem) => mediaItem && mediaItem.type === "video",
|
||||||
);
|
);
|
||||||
|
|
||||||
return videoMedia?.url ? `${videoMedia.url}` : undefined;
|
return videoMedia?.url ? getMediaUrl(videoMedia.url) : undefined;
|
||||||
};
|
};
|
||||||
const getProductImage = (product: Product): string => {
|
const getProductImage = (product: Product): string => {
|
||||||
// Pas de media ? Image placeholder
|
// Pas de media ? Image placeholder
|
||||||
@@ -99,7 +99,7 @@ function UserAccueil() {
|
|||||||
|
|
||||||
// Vérifier si c'est un objet avec type "image" et url
|
// Vérifier si c'est un objet avec type "image" et url
|
||||||
if (mediaItem && mediaItem.type === "image" && mediaItem.url) {
|
if (mediaItem && mediaItem.type === "image" && mediaItem.url) {
|
||||||
return `${mediaItem.url}`;
|
return getMediaUrl(mediaItem.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +107,21 @@ function UserAccueil() {
|
|||||||
return "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image";
|
return "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const grosSemiStyle =
|
||||||
|
selectedCategory === "gros&semi"
|
||||||
|
? {
|
||||||
|
backgroundImage: `url('/logo-gros-semi.png')`,
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundPosition: "center center",
|
||||||
|
backgroundSize: "contain",
|
||||||
|
backgroundAttachment: "local",
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="user-page-container">
|
<div className="user-page-container" style={grosSemiStyle}>
|
||||||
<div className="category-filter">
|
<div className="category-filter">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<button
|
<button
|
||||||
@@ -136,18 +147,6 @@ function UserAccueil() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="error-container">
|
|
||||||
<p className="error-message">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && products.length === 0 && (
|
|
||||||
<div className="empty-container">
|
|
||||||
<p>Aucun produit disponible dans cette catégorie.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && products.length > 0 && (
|
{!loading && !error && products.length > 0 && (
|
||||||
<div className="products-grid">
|
<div className="products-grid">
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
@@ -159,7 +158,7 @@ function UserAccueil() {
|
|||||||
id={product.id}
|
id={product.id}
|
||||||
name={product.name}
|
name={product.name}
|
||||||
price={getProductPrice(product)}
|
price={getProductPrice(product)}
|
||||||
unit="g"
|
unit={product.unit || "g"}
|
||||||
image={getProductImage(product)}
|
image={getProductImage(product)}
|
||||||
stock={product.stock}
|
stock={product.stock}
|
||||||
category={product.category}
|
category={product.category}
|
||||||
@@ -171,6 +170,11 @@ function UserAccueil() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedCategory === "gros&semi" && (
|
||||||
|
<div className="coming-soon-overlay">
|
||||||
|
<span className="coming-soon-text">Prochainement</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useCart } from "../../context/CartContext";
|
|||||||
import Navbar from "../../components/Navbar";
|
import Navbar from "../../components/Navbar";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { isUserAuthenticated, getProductById } from "../../api/api";
|
import { isUserAuthenticated, getProductById, getMediaUrl } from "../../api/api";
|
||||||
import type { Product } from "../../api/api";
|
import type { Product } from "../../api/api";
|
||||||
import { Trash2, ShoppingBag, AlertTriangle } from "lucide-react";
|
import { Trash2, ShoppingBag, AlertTriangle } from "lucide-react";
|
||||||
import "./Cart.css";
|
import "./Cart.css";
|
||||||
@@ -111,7 +111,7 @@ function Cart() {
|
|||||||
for (let i = 0; i < product.media.length; i++) {
|
for (let i = 0; i < product.media.length; i++) {
|
||||||
const mediaItem = product.media[i];
|
const mediaItem = product.media[i];
|
||||||
if (mediaItem && mediaItem.type === "image" && mediaItem.url) {
|
if (mediaItem && mediaItem.type === "image" && mediaItem.url) {
|
||||||
return `${mediaItem.url}`;
|
return getMediaUrl(mediaItem.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ function Cart() {
|
|||||||
(mediaItem) => mediaItem && mediaItem.type === "video",
|
(mediaItem) => mediaItem && mediaItem.type === "video",
|
||||||
);
|
);
|
||||||
|
|
||||||
return videoMedia?.url ? `${videoMedia.url}` : undefined;
|
return videoMedia?.url ? getMediaUrl(videoMedia.url) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
getCommandItemsWithDetails,
|
getCommandItemsWithDetails,
|
||||||
isUserAuthenticated,
|
isUserAuthenticated,
|
||||||
getProductById,
|
getProductById,
|
||||||
|
getMediaUrl,
|
||||||
} from "../../api/api";
|
} from "../../api/api";
|
||||||
import type { CompletedOrder, Product } from "../../api/api_types";
|
import type { CompletedOrder, Product } from "../../api/api_types";
|
||||||
import {
|
import {
|
||||||
@@ -117,7 +118,7 @@ function OrderDetails() {
|
|||||||
for (let i = 0; i < product.media.length; i++) {
|
for (let i = 0; i < product.media.length; i++) {
|
||||||
const mediaItem = product.media[i];
|
const mediaItem = product.media[i];
|
||||||
if (mediaItem && mediaItem.type === "image" && mediaItem.url) {
|
if (mediaItem && mediaItem.type === "image" && mediaItem.url) {
|
||||||
return `${mediaItem.url}`;
|
return getMediaUrl(mediaItem.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +143,7 @@ function OrderDetails() {
|
|||||||
(mediaItem) => mediaItem && mediaItem.type === "video",
|
(mediaItem) => mediaItem && mediaItem.type === "video",
|
||||||
);
|
);
|
||||||
|
|
||||||
return videoMedia?.url ? `${videoMedia.url}` : undefined;
|
return videoMedia?.url ? getMediaUrl(videoMedia.url) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// HANDLERS VIDÉO
|
// HANDLERS VIDÉO
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ function ProductDetail() {
|
|||||||
// ✅ Toast de succès
|
// ✅ Toast de succès
|
||||||
setToast({
|
setToast({
|
||||||
show: true,
|
show: true,
|
||||||
message: `${product.name} (${selectedGrams}g) ajouté au panier !`,
|
message: `${product.name} (${selectedGrams}${product.unit || "g"}) ajouté au panier !`,
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -214,7 +214,7 @@ function ProductDetail() {
|
|||||||
{selectedPrice > 0 && (
|
{selectedPrice > 0 && (
|
||||||
<p className="product-detail-price">
|
<p className="product-detail-price">
|
||||||
{selectedPrice.toFixed(2)} €{" "}
|
{selectedPrice.toFixed(2)} €{" "}
|
||||||
{selectedGrams && `pour ${selectedGrams}g`}
|
{selectedGrams && `pour ${selectedGrams}${product.unit || "g"}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ function ProductDetail() {
|
|||||||
key={p.quantity}
|
key={p.quantity}
|
||||||
value={p.quantity}
|
value={p.quantity}
|
||||||
>
|
>
|
||||||
{p.quantity}g -{" "}
|
{p.quantity}{product.unit || "g"} -{" "}
|
||||||
{p.price.toFixed(2)} €
|
{p.price.toFixed(2)} €
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,319 +1,369 @@
|
|||||||
.user-page-container {
|
@font-face {
|
||||||
width: 100%;
|
font-family: "Reach fill & Outline";
|
||||||
min-height: 100vh;
|
src:
|
||||||
padding: clamp(1rem, 3vw, 1.5rem);
|
url("/fonts/reach-fill-outline.woff2") format("woff2"),
|
||||||
padding-top: calc(60px + clamp(1rem, 3vw, 1.5rem));
|
url("/fonts/reach-fill-outline.woff") format("woff");
|
||||||
box-sizing: border-box;
|
font-weight: normal;
|
||||||
overflow-x: hidden;
|
font-style: normal;
|
||||||
margin-top: 0;
|
}
|
||||||
background-color: #1a1a1a;
|
|
||||||
|
.user-page-container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: clamp(1rem, 3vw, 1.5rem);
|
||||||
|
padding-top: calc(60px + clamp(1rem, 3vw, 1.5rem));
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-x: hidden;
|
||||||
|
margin-top: 0;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== COMING SOON OVERLAY (Gros&Semi) ===== */
|
||||||
|
.coming-soon-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding-bottom: 12vh;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-text {
|
||||||
|
font-family: "Reach fill & Outline", sans-serif;
|
||||||
|
font-size: clamp(1.5rem, 8vw, 0rem);
|
||||||
|
font-weight: 400;
|
||||||
|
color: #8e8fe8;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow:
|
||||||
|
0 0 10px rgba(142, 143, 232, 0.9),
|
||||||
|
0 0 25px rgba(142, 143, 232, 0.6),
|
||||||
|
0 0 50px rgba(142, 143, 232, 0.3);
|
||||||
|
animation: pulse-scale 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-scale {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.35);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-filter {
|
.category-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: clamp(0.5rem, 2vw, 0.8rem);
|
gap: clamp(0.5rem, 2vw, 0.8rem);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: clamp(1rem, 3vw, 1.5rem);
|
padding-bottom: clamp(1rem, 3vw, 1.5rem);
|
||||||
margin-bottom: clamp(1rem, 3vw, 1.5rem);
|
margin-bottom: clamp(1rem, 3vw, 1.5rem);
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-filter::-webkit-scrollbar {
|
.category-filter::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button {
|
.category-button {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: white;
|
color: white;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
padding: clamp(0.6rem, 2vw, 0.8rem) clamp(1.2rem, 3vw, 1.5rem);
|
padding: clamp(0.6rem, 2vw, 0.8rem) clamp(1.2rem, 3vw, 1.5rem);
|
||||||
font-size: clamp(0.9rem, 3vw, 1rem);
|
font-size: clamp(0.9rem, 3vw, 1rem);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button:active {
|
.category-button:active {
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button.active {
|
.category-button.active {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
color: black;
|
color: black;
|
||||||
border-color: white;
|
border-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Effets néon par catégorie - BOUTONS */
|
/* Effets néon par catégorie - BOUTONS */
|
||||||
.category-button.active[data-category="tous"] {
|
.category-button.active[data-category="tous"] {
|
||||||
background-color: #9333ea;
|
background-color: #9333ea;
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #9333ea;
|
border-color: #9333ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button.active[data-category="weed&hash"] {
|
.category-button.active[data-category="weed&hash"] {
|
||||||
background-color: #10b981;
|
background-color: #10b981;
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #10b981;
|
border-color: #10b981;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button.active[data-category="zipette&co"] {
|
.category-button.active[data-category="zipette&co"] {
|
||||||
background-color: #F5F5F0;
|
background-color: #f5f5f0;
|
||||||
color: black;
|
color: black;
|
||||||
border-color: #F5F5F0;
|
border-color: #f5f5f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button.active[data-category="gros&semi"] {
|
.category-button.active[data-category="gros&semi"] {
|
||||||
background-color: #3dc2f7;
|
background-color: #3dc2f7;
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #3dc2f7;
|
border-color: #3dc2f7;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== CATEGORY HEADER ===== */
|
/* ===== CATEGORY HEADER ===== */
|
||||||
.category-header {
|
.category-header {
|
||||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||||
animation: headerFadeIn 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
animation: headerFadeIn 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes headerFadeIn {
|
@keyframes headerFadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-10px);
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-title {
|
.category-title {
|
||||||
font-size: clamp(1.8rem, 5vw, 2.8rem);
|
font-size: clamp(1.8rem, 5vw, 2.8rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: white;
|
||||||
margin: 0 0 0.8rem 0;
|
margin: 0 0 0.8rem 0;
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
background: linear-gradient(135deg, #ffffff 0%, rgba(255, 255, 255, 0.8) 100%);
|
background: linear-gradient(
|
||||||
-webkit-background-clip: text;
|
135deg,
|
||||||
-webkit-text-fill-color: transparent;
|
#ffffff 0%,
|
||||||
background-clip: text;
|
rgba(255, 255, 255, 0.8) 100%
|
||||||
|
);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-subtitle {
|
.category-subtitle {
|
||||||
font-size: clamp(0.95rem, 3vw, 1.1rem);
|
font-size: clamp(0.95rem, 3vw, 1.1rem);
|
||||||
color: rgba(255, 255, 255, 0.65);
|
color: rgba(255, 255, 255, 0.65);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Carrousel de produits - un produit à la fois */
|
/* Carrousel de produits - un produit à la fois */
|
||||||
.products-grid {
|
.products-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scroll-snap-type: x mandatory;
|
scroll-snap-type: x mandatory;
|
||||||
gap: clamp(1rem, 3vw, 1.5rem);
|
gap: clamp(1rem, 3vw, 1.5rem);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
padding: 0 clamp(1rem, 3vw, 1.5rem);
|
padding: 0 clamp(1rem, 3vw, 1.5rem);
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.products-grid::-webkit-scrollbar {
|
.products-grid::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.products-grid > * {
|
.products-grid > * {
|
||||||
flex: 0 0 calc(100% - clamp(2rem, 6vw, 3rem));
|
flex: 0 0 calc(100% - clamp(2rem, 6vw, 3rem));
|
||||||
scroll-snap-align: center;
|
scroll-snap-align: center;
|
||||||
scroll-snap-stop: always;
|
scroll-snap-stop: always;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Effets néon par catégorie - CONTAINERS DE PRODUITS */
|
/* Effets néon par catégorie - CONTAINERS DE PRODUITS */
|
||||||
|
|
||||||
/* Catégorie: tous - VIOLET - Effet néon amélioré */
|
/* Catégorie: tous - VIOLET - Effet néon amélioré */
|
||||||
.products-grid > div[data-category="tous"] .product-card {
|
.products-grid > div[data-category="tous"] .product-card {
|
||||||
border: 2px solid #9333ea;
|
border: 2px solid #9333ea;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 20px rgba(147, 51, 234, 0.6),
|
0 0 20px rgba(147, 51, 234, 0.6),
|
||||||
0 0 40px rgba(147, 51, 234, 0.4),
|
0 0 40px rgba(147, 51, 234, 0.4),
|
||||||
0 0 60px rgba(147, 51, 234, 0.2),
|
0 0 60px rgba(147, 51, 234, 0.2),
|
||||||
0 0 80px rgba(147, 51, 234, 0.1);
|
0 0 80px rgba(147, 51, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Catégorie: weed&hash - VERT - Effet néon amélioré */
|
/* Catégorie: weed&hash - VERT - Effet néon amélioré */
|
||||||
.products-grid > div[data-category="weed&hash"] .product-card {
|
.products-grid > div[data-category="weed&hash"] .product-card {
|
||||||
border: 2px solid #10b981;
|
border: 2px solid #10b981;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 20px rgba(16, 185, 129, 0.6),
|
0 0 20px rgba(16, 185, 129, 0.6),
|
||||||
0 0 40px rgba(16, 185, 129, 0.4),
|
0 0 40px rgba(16, 185, 129, 0.4),
|
||||||
0 0 60px rgba(16, 185, 129, 0.2),
|
0 0 60px rgba(16, 185, 129, 0.2),
|
||||||
0 0 80px rgba(16, 185, 129, 0.1);
|
0 0 80px rgba(16, 185, 129, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Catégorie: zipette&co - BLANC CASSÉ - Effet néon amélioré */
|
/* Catégorie: zipette&co - BLANC CASSÉ - Effet néon amélioré */
|
||||||
.products-grid > div[data-category="zipette&co"] .product-card {
|
.products-grid > div[data-category="zipette&co"] .product-card {
|
||||||
border: 2px solid #F5F5F0;
|
border: 2px solid #f5f5f0;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 20px rgba(245, 245, 240, 0.6),
|
0 0 20px rgba(245, 245, 240, 0.6),
|
||||||
0 0 40px rgba(245, 245, 240, 0.4),
|
0 0 40px rgba(245, 245, 240, 0.4),
|
||||||
0 0 60px rgba(245, 245, 240, 0.2),
|
0 0 60px rgba(245, 245, 240, 0.2),
|
||||||
0 0 80px rgba(245, 245, 240, 0.1);
|
0 0 80px rgba(245, 245, 240, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Catégorie: gros&semi - BLEU CIEL - Effet néon amélioré */
|
/* Catégorie: gros&semi - BLEU CIEL - Effet néon amélioré */
|
||||||
.products-grid > div[data-category="gros&semi"] .product-card {
|
.products-grid > div[data-category="gros&semi"] .product-card {
|
||||||
border: 2px solid #3dc2f7;
|
border: 2px solid #3dc2f7;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 20px rgba(61, 194, 247, 0.6),
|
0 0 20px rgba(61, 194, 247, 0.6),
|
||||||
0 0 40px rgba(61, 194, 247, 0.4),
|
0 0 40px rgba(61, 194, 247, 0.4),
|
||||||
0 0 60px rgba(61, 194, 247, 0.2),
|
0 0 60px rgba(61, 194, 247, 0.2),
|
||||||
0 0 80px rgba(61, 194, 247, 0.1);
|
0 0 80px rgba(61, 194, 247, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Petits téléphones */
|
/* Petits téléphones */
|
||||||
@media (max-width: 360px) {
|
@media (max-width: 360px) {
|
||||||
.products-grid {
|
.products-grid {
|
||||||
padding: 0 0.8rem;
|
padding: 0 0.8rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.products-grid > * {
|
|
||||||
flex: 0 0 calc(100% - 1.6rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-page-container {
|
|
||||||
padding: 0.8rem;
|
|
||||||
padding-top: calc(60px + 0.8rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-title {
|
.products-grid > * {
|
||||||
font-size: 1.6rem;
|
flex: 0 0 calc(100% - 1.6rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-subtitle {
|
.user-page-container {
|
||||||
font-size: 0.9rem;
|
padding: 0.8rem;
|
||||||
}
|
padding-top: calc(60px + 0.8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablettes portrait */
|
/* Tablettes portrait */
|
||||||
@media (min-width: 600px) {
|
@media (min-width: 600px) {
|
||||||
.products-grid {
|
.products-grid {
|
||||||
padding: 0 2rem;
|
padding: 0 2rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.products-grid > * {
|
.products-grid > * {
|
||||||
flex: 0 0 calc(100% - 4rem);
|
flex: 0 0 calc(100% - 4rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-page-container {
|
.user-page-container {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
padding-top: calc(60px + 2rem);
|
padding-top: calc(60px + 2rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablettes paysage et desktop */
|
/* Tablettes paysage et desktop */
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
.products-grid {
|
.products-grid {
|
||||||
padding: 0 2rem;
|
padding: 0 2rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.products-grid > * {
|
.products-grid > * {
|
||||||
flex: 0 0 calc(50% - 2rem);
|
flex: 0 0 calc(50% - 2rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Désactiver hover sur tactile */
|
/* Désactiver hover sur tactile */
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.category-button:hover {
|
.category-button:hover {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button.active:hover {
|
.category-button.active:hover {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button.active[data-category="tous"]:hover {
|
.category-button.active[data-category="tous"]:hover {
|
||||||
background-color: #9333ea;
|
background-color: #9333ea;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button.active[data-category="weed&hash"]:hover {
|
.category-button.active[data-category="weed&hash"]:hover {
|
||||||
background-color: #10b981;
|
background-color: #10b981;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button.active[data-category="zipette&co"]:hover {
|
.category-button.active[data-category="zipette&co"]:hover {
|
||||||
background-color: #F5F5F0;
|
background-color: #f5f5f0;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button.active[data-category="gros&semi"]:hover {
|
.category-button.active[data-category="gros&semi"]:hover {
|
||||||
background-color: #3dc2f7;
|
background-color: #3dc2f7;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-button.active[data-category="festif"]:hover {
|
.category-button.active[data-category="festif"]:hover {
|
||||||
background-color: #9333ea;
|
background-color: #9333ea;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-container,
|
.loading-container,
|
||||||
.error-container,
|
.error-container,
|
||||||
.empty-container {
|
.empty-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-container p,
|
.loading-container p,
|
||||||
.empty-container p {
|
.empty-container p {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: #ff4444;
|
color: #ff4444;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-container button {
|
.error-container button {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-container button:hover {
|
.error-container button:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-container button:active {
|
.error-container button:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user