chore: add new features
This commit is contained in:
@@ -2150,11 +2150,7 @@ graph LR
|
||||
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 |
|
||||
|
||||
---
|
||||
@@ -2301,11 +2297,7 @@ Le systeme bascule automatiquement sur le calcul local:
|
||||
- Estime l'ETA avec une vitesse moyenne de 30 km/h
|
||||
- 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
|
||||
}
|
||||
|
||||
// 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] Description: %s", p.GetDescription())
|
||||
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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ type ProfileResponse struct {
|
||||
|
||||
var (
|
||||
clientTokenDuration = 5 * time.Hour
|
||||
adminTokenDuration = 2 * time.Hour
|
||||
adminTokenDuration = 10 * time.Hour
|
||||
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET")) // ✅ Pour clients
|
||||
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)
|
||||
|
||||
client, err := database.GetClientByUsername(req.Username)
|
||||
if err != nil {
|
||||
if err != nil || client == nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
@@ -327,13 +327,13 @@ func LoginClient(c *gin.Context) {
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(clientTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"role": "client",
|
||||
"session_id": sessionID,
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"role": "client",
|
||||
"session_id": sessionID,
|
||||
"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
|
||||
// ============================================
|
||||
@@ -464,37 +509,49 @@ func GetAvailableDeliveryPersons(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
if c.GetString("role") != "admin" {
|
||||
// ✅ SÉCURITÉ: Admin ou Cabine
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
// Support deux formats de route: :command_id (admin) ou :id (cabine)
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
LivreurUsername string `json:"livreur_username" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
// Livreur depuis URL param (admin) ou body JSON (cabine)
|
||||
livreurUsername := c.Param("username")
|
||||
if livreurUsername == "" {
|
||||
var req struct {
|
||||
LivreurUsername string `json:"livreur_username" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"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")
|
||||
if err := database.AssignDeliveryPerson(commandID, req.LivreurUsername); err != nil {
|
||||
staffUsername, _ := c.Get("username")
|
||||
if err := database.AssignDeliveryPerson(commandID, livreurUsername); err != nil {
|
||||
log.Printf("❌ [ASSIGN] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur assignation livreur",
|
||||
@@ -504,17 +561,17 @@ func AssignDeliveryPerson(c *gin.Context) {
|
||||
}
|
||||
|
||||
database.AddCommandLog(commandID, "support",
|
||||
fmt.Sprintf("Livreur '%s' assigné manuellement par %s", req.LivreurUsername, adminUsername),
|
||||
adminUsername.(string))
|
||||
fmt.Sprintf("Livreur '%s' assigné manuellement par %s", livreurUsername, staffUsername),
|
||||
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{
|
||||
"success": true,
|
||||
"message": "Livreur assigné avec succès",
|
||||
"command_id": commandID,
|
||||
"livreur": req.LivreurUsername,
|
||||
"assigned_by": adminUsername,
|
||||
"livreur": livreurUsername,
|
||||
"assigned_by": staffUsername,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-contrib/sessions"
|
||||
@@ -21,6 +22,13 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
if err := godotenv.Load(); err != nil {
|
||||
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.PUT("/orders/:id/address", handlers.UpdateCommandAddress)
|
||||
adminGroupV2.POST("/orders/:id/force-validate", handlers.ValidateDelivery)
|
||||
adminGroupV2.POST("/orders/:id/confirm-reception", handlers.StaffApproveDelivery)
|
||||
|
||||
// ============================================
|
||||
// ⭐ AUTO-ASSIGNATION GPS - ROUTES CRITIQUES
|
||||
@@ -234,6 +235,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
cabineGroupV1.Use(middleware.CabineMiddleware)
|
||||
{
|
||||
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.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
|
||||
cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen)
|
||||
|
||||
@@ -159,6 +159,18 @@ export const validateCommand = async (commandId: number) => {
|
||||
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
|
||||
// ============================================
|
||||
|
||||
@@ -31,6 +31,18 @@ export const updateItemStatus = async (itemId: number, status: string) => {
|
||||
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
|
||||
// ============================================
|
||||
@@ -105,6 +117,24 @@ export const deleteCommand = async (commandId: number) => {
|
||||
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) => {
|
||||
try {
|
||||
const { data } = await apiClient.get(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getCommandItems,
|
||||
updateCommandStatus,
|
||||
validateCommand,
|
||||
confirmReceptionAdmin,
|
||||
getDeliveryPersonDetails,
|
||||
} from "../../api/api_admin";
|
||||
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
|
||||
@@ -75,6 +76,16 @@ export default function OrderDetailScreen() {
|
||||
load();
|
||||
}, [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 (
|
||||
livreurUsername: 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(
|
||||
() =>
|
||||
StyleSheet.create({
|
||||
@@ -622,6 +647,15 @@ export default function OrderDetailScreen() {
|
||||
style={{ marginTop: spacing.s }}
|
||||
/>
|
||||
)}
|
||||
{command.status === "livre" && (
|
||||
<Button
|
||||
title="Confirmer la réception"
|
||||
onPress={handleConfirmReception}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
style={{ marginTop: spacing.s }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<AlertModal
|
||||
visible={alert.visible}
|
||||
|
||||
@@ -212,7 +212,7 @@ export default function OrdersScreen() {
|
||||
{new Date(item.created_at).toLocaleString("fr-FR")}
|
||||
</Text>
|
||||
<View style={styles.actions}>
|
||||
{item.status === "pending" && (
|
||||
{!["approved", "cancelled"].includes(item.status) && (
|
||||
<Button
|
||||
title="Assigner"
|
||||
onPress={() => openAssignModal(item.id)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
@@ -14,6 +15,9 @@ import { getAllCommands } from "../../api/api_admin";
|
||||
import {
|
||||
getCommandItems,
|
||||
deleteCommand,
|
||||
confirmReceptionCabine,
|
||||
getCabineLivreursList,
|
||||
assignDeliveryPersonByCabine,
|
||||
} from "../../api/api_cabine";
|
||||
import type { CommandResponse } from "../../api/types";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
@@ -61,6 +65,11 @@ export default function OrdersScreen() {
|
||||
commandInfo: 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 () => {
|
||||
try {
|
||||
@@ -81,6 +90,13 @@ export default function OrdersScreen() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
loadData();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
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) => {
|
||||
showConfirm(
|
||||
"Supprimer",
|
||||
@@ -128,6 +160,27 @@ export default function OrdersScreen() {
|
||||
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(
|
||||
() =>
|
||||
StyleSheet.create({
|
||||
@@ -271,6 +324,16 @@ export default function OrdersScreen() {
|
||||
fontSize: fontSize.xs,
|
||||
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],
|
||||
);
|
||||
@@ -293,6 +356,20 @@ export default function OrdersScreen() {
|
||||
size="sm"
|
||||
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
|
||||
title="Supprimer"
|
||||
onPress={() => handleDelete(item.id)}
|
||||
@@ -461,6 +538,33 @@ export default function OrdersScreen() {
|
||||
</ScrollView>
|
||||
</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
|
||||
visible={alert.visible}
|
||||
type={alert.type}
|
||||
|
||||
@@ -271,6 +271,12 @@ export default function DashboardScreen() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
loadData();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData]);
|
||||
|
||||
// Quand GPS devient disponible, rejouer la route en attente
|
||||
useEffect(() => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 848 KiB |
@@ -6,6 +6,13 @@
|
||||
// ✅ sessionStorage (pas localStorage)
|
||||
|
||||
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 {
|
||||
ConfirmReceptionResponse,
|
||||
CheckoutCartResponse,
|
||||
@@ -291,14 +298,21 @@ export const changePassword = async (
|
||||
if (!response.ok) {
|
||||
return {
|
||||
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) {
|
||||
return {
|
||||
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;
|
||||
description?: string;
|
||||
category: string;
|
||||
unit?: string;
|
||||
stock: number;
|
||||
prices?: Array<{ quantity: number; price: number }>;
|
||||
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
|
||||
|
||||
@@ -365,6 +365,7 @@ export interface Product {
|
||||
description?: string;
|
||||
category: string;
|
||||
stock: number;
|
||||
unit?: string;
|
||||
prices?: ProductPrice[];
|
||||
media?: Array<{
|
||||
// ✅ CHANGÉ
|
||||
|
||||
@@ -20,6 +20,7 @@ function ProductCard({
|
||||
id,
|
||||
name,
|
||||
price,
|
||||
unit = "g",
|
||||
image,
|
||||
stock,
|
||||
category = "autre",
|
||||
@@ -180,7 +181,7 @@ function ProductCard({
|
||||
key={priceOption.quantity}
|
||||
value={priceOption.quantity}
|
||||
>
|
||||
{priceOption.quantity}g -{" "}
|
||||
{priceOption.quantity}{unit} -{" "}
|
||||
{priceOption.price.toFixed(2)} €
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import type { Product, ProductPrice } from "../api/api_types";
|
||||
import { getMediaUrl } from "../api/api";
|
||||
import "./ProductModal.css";
|
||||
|
||||
interface ProductDetailsModalProps {
|
||||
@@ -114,14 +115,14 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
|
||||
<div className="media-viewer">
|
||||
{currentMedia?.type === "image" ? (
|
||||
<img
|
||||
src={`${currentMedia.url}`}
|
||||
src={getMediaUrl(currentMedia.url)}
|
||||
alt={product.name}
|
||||
className="media-display"
|
||||
/>
|
||||
) : currentMedia?.type === "video" ? (
|
||||
<div className="video-container">
|
||||
<video
|
||||
src={`${currentMedia.url}`}
|
||||
src={getMediaUrl(currentMedia.url)}
|
||||
controls
|
||||
className="media-display"
|
||||
>
|
||||
@@ -179,7 +180,7 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
|
||||
>
|
||||
{media.type === "image" ? (
|
||||
<img
|
||||
src={`{product.media[0].url}${media.url}`}
|
||||
src={getMediaUrl(media.url)}
|
||||
alt={`Media ${index + 1}`}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import ProductCard from "../../components/ProductCard";
|
||||
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 "./UserAccueil.css";
|
||||
|
||||
@@ -85,7 +85,7 @@ function UserAccueil() {
|
||||
(mediaItem) => mediaItem && mediaItem.type === "video",
|
||||
);
|
||||
|
||||
return videoMedia?.url ? `${videoMedia.url}` : undefined;
|
||||
return videoMedia?.url ? getMediaUrl(videoMedia.url) : undefined;
|
||||
};
|
||||
const getProductImage = (product: Product): string => {
|
||||
// Pas de media ? Image placeholder
|
||||
@@ -99,7 +99,7 @@ function UserAccueil() {
|
||||
|
||||
// Vérifier si c'est un objet avec type "image" et 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";
|
||||
};
|
||||
|
||||
const grosSemiStyle =
|
||||
selectedCategory === "gros&semi"
|
||||
? {
|
||||
backgroundImage: `url('/logo-gros-semi.png')`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center center",
|
||||
backgroundSize: "contain",
|
||||
backgroundAttachment: "local",
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="user-page-container">
|
||||
<div className="user-page-container" style={grosSemiStyle}>
|
||||
<div className="category-filter">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
@@ -136,18 +147,6 @@ function UserAccueil() {
|
||||
</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 && (
|
||||
<div className="products-grid">
|
||||
{products.map((product) => (
|
||||
@@ -159,7 +158,7 @@ function UserAccueil() {
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
price={getProductPrice(product)}
|
||||
unit="g"
|
||||
unit={product.unit || "g"}
|
||||
image={getProductImage(product)}
|
||||
stock={product.stock}
|
||||
category={product.category}
|
||||
@@ -171,6 +170,11 @@ function UserAccueil() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedCategory === "gros&semi" && (
|
||||
<div className="coming-soon-overlay">
|
||||
<span className="coming-soon-text">Prochainement</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useCart } from "../../context/CartContext";
|
||||
import Navbar from "../../components/Navbar";
|
||||
import { useEffect, useState } from "react";
|
||||
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 { Trash2, ShoppingBag, AlertTriangle } from "lucide-react";
|
||||
import "./Cart.css";
|
||||
@@ -111,7 +111,7 @@ function Cart() {
|
||||
for (let i = 0; i < product.media.length; i++) {
|
||||
const mediaItem = product.media[i];
|
||||
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",
|
||||
);
|
||||
|
||||
return videoMedia?.url ? `${videoMedia.url}` : undefined;
|
||||
return videoMedia?.url ? getMediaUrl(videoMedia.url) : undefined;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getCommandItemsWithDetails,
|
||||
isUserAuthenticated,
|
||||
getProductById,
|
||||
getMediaUrl,
|
||||
} from "../../api/api";
|
||||
import type { CompletedOrder, Product } from "../../api/api_types";
|
||||
import {
|
||||
@@ -117,7 +118,7 @@ function OrderDetails() {
|
||||
for (let i = 0; i < product.media.length; i++) {
|
||||
const mediaItem = product.media[i];
|
||||
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",
|
||||
);
|
||||
|
||||
return videoMedia?.url ? `${videoMedia.url}` : undefined;
|
||||
return videoMedia?.url ? getMediaUrl(videoMedia.url) : undefined;
|
||||
};
|
||||
|
||||
// HANDLERS VIDÉO
|
||||
|
||||
@@ -148,7 +148,7 @@ function ProductDetail() {
|
||||
// ✅ Toast de succès
|
||||
setToast({
|
||||
show: true,
|
||||
message: `${product.name} (${selectedGrams}g) ajouté au panier !`,
|
||||
message: `${product.name} (${selectedGrams}${product.unit || "g"}) ajouté au panier !`,
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
@@ -214,7 +214,7 @@ function ProductDetail() {
|
||||
{selectedPrice > 0 && (
|
||||
<p className="product-detail-price">
|
||||
{selectedPrice.toFixed(2)} €{" "}
|
||||
{selectedGrams && `pour ${selectedGrams}g`}
|
||||
{selectedGrams && `pour ${selectedGrams}${product.unit || "g"}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -252,7 +252,7 @@ function ProductDetail() {
|
||||
key={p.quantity}
|
||||
value={p.quantity}
|
||||
>
|
||||
{p.quantity}g -{" "}
|
||||
{p.quantity}{product.unit || "g"} -{" "}
|
||||
{p.price.toFixed(2)} €
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -1,319 +1,369 @@
|
||||
.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;
|
||||
@font-face {
|
||||
font-family: "Reach fill & Outline";
|
||||
src:
|
||||
url("/fonts/reach-fill-outline.woff2") format("woff2"),
|
||||
url("/fonts/reach-fill-outline.woff") format("woff");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
gap: clamp(0.5rem, 2vw, 0.8rem);
|
||||
overflow-x: auto;
|
||||
padding-bottom: clamp(1rem, 3vw, 1.5rem);
|
||||
margin-bottom: clamp(1rem, 3vw, 1.5rem);
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
display: flex;
|
||||
gap: clamp(0.5rem, 2vw, 0.8rem);
|
||||
overflow-x: auto;
|
||||
padding-bottom: clamp(1rem, 3vw, 1.5rem);
|
||||
margin-bottom: clamp(1rem, 3vw, 1.5rem);
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.category-filter::-webkit-scrollbar {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.category-button {
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 25px;
|
||||
padding: clamp(0.6rem, 2vw, 0.8rem) clamp(1.2rem, 3vw, 1.5rem);
|
||||
font-size: clamp(0.9rem, 3vw, 1rem);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
flex-shrink: 0;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 25px;
|
||||
padding: clamp(0.6rem, 2vw, 0.8rem) clamp(1.2rem, 3vw, 1.5rem);
|
||||
font-size: clamp(0.9rem, 3vw, 1rem);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-button:active {
|
||||
transform: scale(0.95);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.category-button.active {
|
||||
background-color: white;
|
||||
color: black;
|
||||
border-color: white;
|
||||
background-color: white;
|
||||
color: black;
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
/* Effets néon par catégorie - BOUTONS */
|
||||
.category-button.active[data-category="tous"] {
|
||||
background-color: #9333ea;
|
||||
color: white;
|
||||
border-color: #9333ea;
|
||||
background-color: #9333ea;
|
||||
color: white;
|
||||
border-color: #9333ea;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="weed&hash"] {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
border-color: #10b981;
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="zipette&co"] {
|
||||
background-color: #F5F5F0;
|
||||
color: black;
|
||||
border-color: #F5F5F0;
|
||||
background-color: #f5f5f0;
|
||||
color: black;
|
||||
border-color: #f5f5f0;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="gros&semi"] {
|
||||
background-color: #3dc2f7;
|
||||
color: white;
|
||||
border-color: #3dc2f7;
|
||||
|
||||
background-color: #3dc2f7;
|
||||
color: white;
|
||||
border-color: #3dc2f7;
|
||||
}
|
||||
|
||||
/* ===== CATEGORY HEADER ===== */
|
||||
.category-header {
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
animation: headerFadeIn 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
animation: headerFadeIn 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
@keyframes headerFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-size: clamp(1.8rem, 5vw, 2.8rem);
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.8rem 0;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-size: clamp(1.8rem, 5vw, 2.8rem);
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.8rem 0;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#ffffff 0%,
|
||||
rgba(255, 255, 255, 0.8) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.category-subtitle {
|
||||
font-size: clamp(0.95rem, 3vw, 1.1rem);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.3px;
|
||||
line-height: 1.5;
|
||||
font-size: clamp(0.95rem, 3vw, 1.1rem);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.3px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Carrousel de produits - un produit à la fois */
|
||||
.products-grid {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
gap: clamp(1rem, 3vw, 1.5rem);
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding: 0 clamp(1rem, 3vw, 1.5rem);
|
||||
padding-bottom: 1rem;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
gap: clamp(1rem, 3vw, 1.5rem);
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding: 0 clamp(1rem, 3vw, 1.5rem);
|
||||
padding-bottom: 1rem;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.products-grid::-webkit-scrollbar {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.products-grid > * {
|
||||
flex: 0 0 calc(100% - clamp(2rem, 6vw, 3rem));
|
||||
scroll-snap-align: center;
|
||||
scroll-snap-stop: always;
|
||||
flex: 0 0 calc(100% - clamp(2rem, 6vw, 3rem));
|
||||
scroll-snap-align: center;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
|
||||
/* Effets néon par catégorie - CONTAINERS DE PRODUITS */
|
||||
|
||||
/* Catégorie: tous - VIOLET - Effet néon amélioré */
|
||||
.products-grid > div[data-category="tous"] .product-card {
|
||||
border: 2px solid #9333ea;
|
||||
box-shadow:
|
||||
0 0 20px rgba(147, 51, 234, 0.6),
|
||||
0 0 40px rgba(147, 51, 234, 0.4),
|
||||
0 0 60px rgba(147, 51, 234, 0.2),
|
||||
0 0 80px rgba(147, 51, 234, 0.1);
|
||||
border: 2px solid #9333ea;
|
||||
box-shadow:
|
||||
0 0 20px rgba(147, 51, 234, 0.6),
|
||||
0 0 40px rgba(147, 51, 234, 0.4),
|
||||
0 0 60px rgba(147, 51, 234, 0.2),
|
||||
0 0 80px rgba(147, 51, 234, 0.1);
|
||||
}
|
||||
|
||||
/* Catégorie: weed&hash - VERT - Effet néon amélioré */
|
||||
.products-grid > div[data-category="weed&hash"] .product-card {
|
||||
border: 2px solid #10b981;
|
||||
box-shadow:
|
||||
0 0 20px rgba(16, 185, 129, 0.6),
|
||||
0 0 40px rgba(16, 185, 129, 0.4),
|
||||
0 0 60px rgba(16, 185, 129, 0.2),
|
||||
0 0 80px rgba(16, 185, 129, 0.1);
|
||||
border: 2px solid #10b981;
|
||||
box-shadow:
|
||||
0 0 20px rgba(16, 185, 129, 0.6),
|
||||
0 0 40px rgba(16, 185, 129, 0.4),
|
||||
0 0 60px rgba(16, 185, 129, 0.2),
|
||||
0 0 80px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
/* Catégorie: zipette&co - BLANC CASSÉ - Effet néon amélioré */
|
||||
.products-grid > div[data-category="zipette&co"] .product-card {
|
||||
border: 2px solid #F5F5F0;
|
||||
box-shadow:
|
||||
0 0 20px rgba(245, 245, 240, 0.6),
|
||||
0 0 40px rgba(245, 245, 240, 0.4),
|
||||
0 0 60px rgba(245, 245, 240, 0.2),
|
||||
0 0 80px rgba(245, 245, 240, 0.1);
|
||||
border: 2px solid #f5f5f0;
|
||||
box-shadow:
|
||||
0 0 20px rgba(245, 245, 240, 0.6),
|
||||
0 0 40px rgba(245, 245, 240, 0.4),
|
||||
0 0 60px rgba(245, 245, 240, 0.2),
|
||||
0 0 80px rgba(245, 245, 240, 0.1);
|
||||
}
|
||||
|
||||
/* Catégorie: gros&semi - BLEU CIEL - Effet néon amélioré */
|
||||
.products-grid > div[data-category="gros&semi"] .product-card {
|
||||
border: 2px solid #3dc2f7;
|
||||
box-shadow:
|
||||
0 0 20px rgba(61, 194, 247, 0.6),
|
||||
0 0 40px rgba(61, 194, 247, 0.4),
|
||||
0 0 60px rgba(61, 194, 247, 0.2),
|
||||
0 0 80px rgba(61, 194, 247, 0.1);
|
||||
border: 2px solid #3dc2f7;
|
||||
box-shadow:
|
||||
0 0 20px rgba(61, 194, 247, 0.6),
|
||||
0 0 40px rgba(61, 194, 247, 0.4),
|
||||
0 0 60px rgba(61, 194, 247, 0.2),
|
||||
0 0 80px rgba(61, 194, 247, 0.1);
|
||||
}
|
||||
|
||||
/* Petits téléphones */
|
||||
@media (max-width: 360px) {
|
||||
.products-grid {
|
||||
padding: 0 0.8rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.products-grid > * {
|
||||
flex: 0 0 calc(100% - 1.6rem);
|
||||
}
|
||||
|
||||
.user-page-container {
|
||||
padding: 0.8rem;
|
||||
padding-top: calc(60px + 0.8rem);
|
||||
}
|
||||
.products-grid {
|
||||
padding: 0 0.8rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.products-grid > * {
|
||||
flex: 0 0 calc(100% - 1.6rem);
|
||||
}
|
||||
|
||||
.category-subtitle {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.user-page-container {
|
||||
padding: 0.8rem;
|
||||
padding-top: calc(60px + 0.8rem);
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.category-subtitle {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablettes portrait */
|
||||
@media (min-width: 600px) {
|
||||
.products-grid {
|
||||
padding: 0 2rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.products-grid > * {
|
||||
flex: 0 0 calc(100% - 4rem);
|
||||
}
|
||||
|
||||
.user-page-container {
|
||||
padding: 2rem;
|
||||
padding-top: calc(60px + 2rem);
|
||||
}
|
||||
.products-grid {
|
||||
padding: 0 2rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.products-grid > * {
|
||||
flex: 0 0 calc(100% - 4rem);
|
||||
}
|
||||
|
||||
.user-page-container {
|
||||
padding: 2rem;
|
||||
padding-top: calc(60px + 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablettes paysage et desktop */
|
||||
@media (min-width: 900px) {
|
||||
.products-grid {
|
||||
padding: 0 2rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.products-grid > * {
|
||||
flex: 0 0 calc(50% - 2rem);
|
||||
}
|
||||
.products-grid {
|
||||
padding: 0 2rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.products-grid > * {
|
||||
flex: 0 0 calc(50% - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Désactiver hover sur tactile */
|
||||
@media (hover: none) {
|
||||
.category-button:hover {
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-button.active:hover {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="tous"]:hover {
|
||||
background-color: #9333ea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="weed&hash"]:hover {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="zipette&co"]:hover {
|
||||
background-color: #F5F5F0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="gros&semi"]:hover {
|
||||
background-color: #3dc2f7;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="festif"]:hover {
|
||||
background-color: #9333ea;
|
||||
color: white;
|
||||
}
|
||||
.category-button:hover {
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-button.active:hover {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="tous"]:hover {
|
||||
background-color: #9333ea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="weed&hash"]:hover {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="zipette&co"]:hover {
|
||||
background-color: #f5f5f0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="gros&semi"]:hover {
|
||||
background-color: #3dc2f7;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-button.active[data-category="festif"]:hover {
|
||||
background-color: #9333ea;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
min-height: 300px;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
min-height: 300px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-container p,
|
||||
.empty-container p {
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff4444;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: #ff4444;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.error-container button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user