chore: add new features

This commit is contained in:
2026-03-05 19:09:16 +01:00
parent 532584edb2
commit 5ca480ea0b
23 changed files with 695 additions and 284 deletions
+1 -9
View File
@@ -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
--- ---
+91
View File
@@ -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(&currentStatus, &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
}
+1
View File
@@ -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()))
} }
+9 -9
View File
@@ -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,
}, },
}) })
+79 -22
View File
@@ -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,
}) })
} }
+8
View File
@@ -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.")
+3
View File
@@ -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)
+12
View File
@@ -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
// ============================================ // ============================================
+30
View File
@@ -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

+18 -3
View File
@@ -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[]
+1
View File
@@ -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É
+2 -1
View File
@@ -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}`}
/> />
) : ( ) : (
+21 -17
View File
@@ -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>
</> </>
); );
+3 -3
View File
@@ -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>
))} ))}
+261 -211
View File
@@ -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);
} }