chore: update

This commit is contained in:
2026-03-03 23:42:23 +01:00
parent 52b059fafa
commit 0073f80a48
95 changed files with 2720 additions and 40619 deletions
@@ -1,12 +0,0 @@
.admin-layout {
display: flex;
min-height: 100vh;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
}
.admin-content {
flex: 1;
margin-left: 0;
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow-x: hidden;
}
@@ -1,19 +0,0 @@
import Sidebar from './Sidebar';
import './AdminLayout.css';
interface AdminLayoutProps {
children: React.ReactNode;
}
function AdminLayout({ children }: AdminLayoutProps) {
return (
<div className="admin-layout">
<Sidebar />
<main className="admin-content">
{children}
</main>
</div>
);
}
export default AdminLayout;
@@ -1,388 +0,0 @@
/* ============================================
CONFIRM MODAL - STYLES PROFESSIONNELS
============================================ */
.confirm-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 1rem;
animation: overlayFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes overlayFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* ============================================
CONTAINER
============================================ */
.confirm-modal-container {
background: linear-gradient(
135deg,
rgba(30, 30, 40, 0.98) 0%,
rgba(20, 20, 30, 0.98) 100%
);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
width: 100%;
max-width: 480px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.5),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
animation: modalSlideIn 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
overflow: hidden;
}
@keyframes modalSlideIn {
from {
transform: scale(0.9) translateY(20px);
opacity: 0;
}
to {
transform: scale(1) translateY(0);
opacity: 1;
}
}
/* ============================================
HEADER
============================================ */
.confirm-modal-header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 2rem 1.5rem 2rem;
}
.confirm-modal-icon {
width: 72px;
height: 72px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
animation: iconPulse 2s ease-in-out infinite;
}
@keyframes iconPulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.confirm-modal-icon-danger {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.2),
rgba(220, 38, 38, 0.1)
);
border: 2px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
box-shadow: 0 0 30px rgba(239, 68, 68, 0.3);
}
.confirm-modal-icon-warning {
background: linear-gradient(
135deg,
rgba(245, 158, 11, 0.2),
rgba(217, 119, 6, 0.1)
);
border: 2px solid rgba(245, 158, 11, 0.3);
color: #f59e0b;
box-shadow: 0 0 30px rgba(245, 158, 11, 0.3);
}
.confirm-modal-icon-info {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.2),
rgba(37, 99, 235, 0.1)
);
border: 2px solid rgba(59, 130, 246, 0.3);
color: #3b82f6;
box-shadow: 0 0 30px rgba(59, 130, 246, 0.3);
}
.confirm-modal-icon-success {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.2),
rgba(5, 150, 105, 0.1)
);
border: 2px solid rgba(16, 185, 129, 0.3);
color: #10b981;
box-shadow: 0 0 30px rgba(16, 185, 129, 0.3);
}
.confirm-modal-close {
position: absolute;
top: 1.5rem;
right: 1.5rem;
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #888;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.confirm-modal-close:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: white;
transform: rotate(90deg);
}
.confirm-modal-close:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ============================================
CONTENT
============================================ */
.confirm-modal-content {
padding: 0 2rem 2rem 2rem;
text-align: center;
}
.confirm-modal-title {
color: white;
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 1rem 0;
letter-spacing: -0.5px;
line-height: 1.3;
}
.confirm-modal-message {
color: rgba(255, 255, 255, 0.7);
font-size: 1rem;
line-height: 1.6;
margin: 0;
max-width: 380px;
margin: 0 auto;
}
/* ============================================
FOOTER
============================================ */
.confirm-modal-footer {
display: flex;
gap: 1rem;
padding: 0 2rem 2rem 2rem;
}
.confirm-modal-btn {
flex: 1;
padding: 1rem 1.5rem;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: 1px solid transparent;
}
.confirm-modal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Cancel Button */
.confirm-modal-btn-cancel {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
}
.confirm-modal-btn-cancel:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: white;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
/* Confirm Buttons - Variants */
.confirm-modal-btn-confirm {
font-weight: 700;
}
.confirm-modal-btn-danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
border-color: rgba(239, 68, 68, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.confirm-modal-btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #f87171, #ef4444);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(239, 68, 68, 0.5);
}
.confirm-modal-btn-warning {
background: linear-gradient(135deg, #f59e0b, #d97706);
border-color: rgba(245, 158, 11, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(245, 158, 11, 0.3);
}
.confirm-modal-btn-warning:hover:not(:disabled) {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(245, 158, 11, 0.5);
}
.confirm-modal-btn-info {
background: linear-gradient(135deg, #3b82f6, #2563eb);
border-color: rgba(59, 130, 246, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
}
.confirm-modal-btn-info:hover:not(:disabled) {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.5);
}
.confirm-modal-btn-success {
background: linear-gradient(135deg, #10b981, #059669);
border-color: rgba(16, 185, 129, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
}
.confirm-modal-btn-success:hover:not(:disabled) {
background: linear-gradient(135deg, #34d399, #10b981);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.5);
}
/* ============================================
LOADING STATE
============================================ */
.confirm-modal-loading {
display: flex;
align-items: center;
gap: 0.6rem;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 640px) {
.confirm-modal-container {
max-width: 100%;
margin: 1rem;
border-radius: 16px;
}
.confirm-modal-header {
padding: 1.5rem 1.5rem 1rem 1.5rem;
}
.confirm-modal-content {
padding: 0 1.5rem 1.5rem 1.5rem;
}
.confirm-modal-footer {
flex-direction: column;
padding: 0 1.5rem 1.5rem 1.5rem;
}
.confirm-modal-btn {
width: 100%;
}
.confirm-modal-icon {
width: 64px;
height: 64px;
}
.confirm-modal-title {
font-size: 1.3rem;
}
.confirm-modal-message {
font-size: 0.95rem;
}
.confirm-modal-close {
top: 1rem;
right: 1rem;
width: 32px;
height: 32px;
}
}
@media (max-width: 480px) {
.confirm-modal-icon {
width: 56px;
height: 56px;
}
.confirm-modal-title {
font-size: 1.2rem;
}
}
/* Mobile Touch Optimization */
@media (hover: none) {
.confirm-modal-btn:hover {
transform: none;
}
.confirm-modal-close:hover {
transform: none;
}
}
@@ -1,114 +0,0 @@
import { X, AlertTriangle, Trash2, CheckCircle, Info } from "lucide-react";
import "./ConfirmModal.css";
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
type?: "danger" | "warning" | "info" | "success";
confirmText?: string;
cancelText?: string;
isLoading?: boolean;
}
export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
type = "warning",
confirmText = "Confirmer",
cancelText = "Annuler",
isLoading = false,
}: ConfirmModalProps) {
if (!isOpen) return null;
const getIcon = () => {
switch (type) {
case "danger":
return <Trash2 size={28} />;
case "warning":
return <AlertTriangle size={28} />;
case "success":
return <CheckCircle size={28} />;
case "info":
return <Info size={28} />;
default:
return <AlertTriangle size={28} />;
}
};
const handleConfirm = () => {
if (!isLoading) {
onConfirm();
}
};
const handleCancel = () => {
if (!isLoading) {
onClose();
}
};
return (
<div className="confirm-modal-overlay" onClick={handleCancel}>
<div
className="confirm-modal-container"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="confirm-modal-header">
<div
className={`confirm-modal-icon confirm-modal-icon-${type}`}
>
{getIcon()}
</div>
<button
className="confirm-modal-close"
onClick={handleCancel}
disabled={isLoading}
aria-label="Fermer"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="confirm-modal-content">
<h2 className="confirm-modal-title">{title}</h2>
<p className="confirm-modal-message">{message}</p>
</div>
{/* Footer */}
<div className="confirm-modal-footer">
<button
className="confirm-modal-btn confirm-modal-btn-cancel"
onClick={handleCancel}
disabled={isLoading}
>
{cancelText}
</button>
<button
className={`confirm-modal-btn confirm-modal-btn-confirm confirm-modal-btn-${type}`}
onClick={handleConfirm}
disabled={isLoading}
>
{isLoading ? (
<span className="confirm-modal-loading">
<span className="spinner" />
Chargement...
</span>
) : (
confirmText
)}
</button>
</div>
</div>
</div>
);
}
export default ConfirmModal;
@@ -1,429 +0,0 @@
/* ============================================
EditUserModal.css
============================================ */
/* ✅ Modal overlay - PAS DE Z-INDEX */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
/* ✅ PAS DE Z-INDEX pour ne pas cacher la modal */
backdrop-filter: blur(8px);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.edit-user-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 700px;
max-height: 90vh;
background: linear-gradient(135deg, #1a1a1a, #0f0f0f);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 20px;
z-index: 9999; /* ✅ Seule la modal a un z-index */
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8);
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, -45%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
/* Alerts */
.alert {
padding: 1rem 1.2rem;
border-radius: 10px;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.8rem;
font-size: 0.95rem;
font-weight: 600;
animation: slideInDown 0.3s ease;
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.alert-error {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.1));
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.alert-success {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.1));
border: 1px solid rgba(16, 185, 129, 0.3);
color: #10b981;
}
/* Form sections */
.form-section {
margin-bottom: 2rem;
}
.form-section:last-child {
margin-bottom: 0;
}
.form-section h3 {
color: white;
font-size: 1.1rem;
margin: 0 0 1rem 0;
font-weight: bold;
border-bottom: 2px solid rgba(124, 58, 237, 0.3);
padding-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.2rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
color: #888;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-group label svg {
color: #7c3aed;
}
.form-group input,
.form-group select {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 0.9rem 1rem;
color: white;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
}
.form-group input:focus,
.form-group select:focus {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
border-color: rgba(124, 58, 237, 0.4);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
}
.form-group input::placeholder {
color: #666;
}
.form-group input[type="number"] {
appearance: textfield;
}
.form-group input[type="number"]::-webkit-inner-spin-button,
.form-group input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.role-select {
cursor: pointer;
background: #1a1a1a !important;
width: 100%;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.custom-select-wrapper {
position: relative;
width: 100%;
}
.custom-select-wrapper .select-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: #7c3aed;
display: flex;
align-items: center;
z-index: 1;
}
.custom-select-wrapper .select-arrow {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: #888;
font-size: 0.7rem;
}
.role-select {
padding-left: 3rem !important;
}
.role-select option {
background-color: #1a1a1a !important;
color: white !important;
padding: 0.8rem !important;
}
.role-select option:hover,
.role-select option:focus,
.role-select option:active {
background-color: #0f0f0f !important;
background: #0f0f0f !important;
color: white !important;
outline: none !important;
box-shadow: none !important;
}
.role-select option:checked {
background-color: #7c3aed !important;
background: #7c3aed !important;
color: white !important;
font-weight: 600;
}
@-moz-document url-prefix() {
.role-select option:hover {
background-color: #0f0f0f !important;
}
}
.role-select::-ms-expand {
display: none;
}
.role-select option::selection {
background: #0f0f0f !important;
}
.role-select option::-moz-selection {
background: #0f0f0f !important;
}
/* Spinner */
.spinner {
width: 18px;
height: 18px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Modal header et actions - Styles manquants */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.modal-header h2 {
color: white;
font-size: 1.5rem;
margin: 0;
font-weight: bold;
}
.close-modal {
background: transparent;
border: none;
color: #888;
cursor: pointer;
transition: all 0.3s ease;
padding: 0.5rem;
border-radius: 8px;
}
.close-modal:hover {
color: white;
background: rgba(255, 255, 255, 0.05);
transform: rotate(90deg);
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
}
.modal-content::-webkit-scrollbar {
width: 8px;
}
.modal-content::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
}
.modal-content::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.3);
border-radius: 4px;
}
.modal-actions {
display: flex;
gap: 1rem;
padding: 1.5rem 2rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
flex-wrap: wrap;
}
.action-button {
flex: 1;
min-width: 150px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 0.9rem 1.5rem;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: none;
}
.action-button.secondary {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
}
.action-button.secondary:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04));
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.action-button.primary {
background: linear-gradient(135deg, #7c3aed, #6d28d9);
border: 1px solid rgba(124, 58, 237, 0.3);
color: white;
}
.action-button.primary:hover {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
/* Responsive */
@media (max-width: 768px) {
.edit-user-modal {
width: 95%;
max-height: 95vh;
}
.form-grid {
grid-template-columns: 1fr;
}
.form-group {
grid-column: 1 / -1 !important;
}
.modal-header,
.modal-actions {
padding: 1rem 1.5rem;
}
.modal-content {
padding: 1.5rem;
}
.modal-actions {
flex-direction: column;
}
.action-button {
width: 100%;
}
}
@media (max-width: 480px) {
.form-section h3 {
font-size: 1rem;
}
.form-group label {
font-size: 0.8rem;
}
.form-group input,
.form-group select {
padding: 0.8rem;
font-size: 0.9rem;
}
}
@@ -1,407 +0,0 @@
// ============================================
// EditUserModal.tsx
// ============================================
// Modal de modification des profils utilisateurs (Admin)
import { useState } from 'react';
import { XCircle, Save, User, Lock, Shield, Phone, Crown, Building2, Truck } from 'lucide-react';
import { updateClientByAdmin, updateUserByAdmin } from '../api/api_admin';
import './EditUserModal.css';
interface EditUserModalProps {
user: {
id: number;
username: string;
role: 'client' | 'livreur' | 'admin' | 'cabine';
nom?: string;
prenom?: string;
telephone?: string;
adresse?: string;
command?: number;
point?: number;
points_zipette?: number; // ✅ AJOUTÉ
amende?: number;
};
onClose: () => void;
onSuccess: () => void;
}
function EditUserModal({ user, onClose, onSuccess }: EditUserModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// État du formulaire pour CLIENT
const [clientForm, setClientForm] = useState({
username: user.username || '',
password: '',
nom: user.nom || '',
prenom: user.prenom || '',
telephone: user.telephone || '',
point: user.point || 0,
points_zipette: user.points_zipette || 0, // ✅ AJOUTÉ
amende: user.amende || 0,
command: user.command || 0,
});
// État du formulaire pour USER (admin/cabine/livreur)
const [userForm, setUserForm] = useState({
username: user.username || '',
password: '',
role: user.role as 'admin' | 'cabine' | 'livreur',
});
const isClient = user.role === 'client';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(null);
try {
if (isClient) {
// ✅ Modification CLIENT
const updates: any = {};
// N'envoyer que les champs modifiés
if (clientForm.username && clientForm.username !== user.username) {
updates.username = clientForm.username;
}
if (clientForm.password) {
updates.password = clientForm.password;
}
if (clientForm.nom && clientForm.nom !== user.nom) {
updates.nom = clientForm.nom;
}
if (clientForm.prenom && clientForm.prenom !== user.prenom) {
updates.prenom = clientForm.prenom;
}
if (clientForm.telephone && clientForm.telephone !== user.telephone) {
updates.telephone = clientForm.telephone;
}
if (clientForm.point !== user.point) {
updates.point = clientForm.point;
}
if (clientForm.points_zipette !== user.points_zipette) { // ✅ AJOUTÉ
updates.points_zipette = clientForm.points_zipette;
}
if (clientForm.amende !== user.amende) {
updates.amende = clientForm.amende;
}
if (clientForm.command !== user.command) {
updates.command = clientForm.command;
}
if (Object.keys(updates).length === 0) {
setError('Aucune modification détectée');
setLoading(false);
return;
}
console.log('📝 [EDIT_CLIENT] Mise à jour:', updates);
const result = await updateClientByAdmin(user.id, updates);
if (result.success) {
setSuccess(result.message || 'Client mis à jour avec succès');
setTimeout(() => {
onSuccess();
onClose();
}, 1500);
}
} else {
// ✅ Modification USER (admin/cabine/livreur)
const updates: any = {};
if (userForm.username && userForm.username !== user.username) {
updates.username = userForm.username;
}
if (userForm.password) {
updates.password = userForm.password;
}
if (userForm.role && userForm.role !== user.role) {
updates.role = userForm.role;
}
if (Object.keys(updates).length === 0) {
setError('Aucune modification détectée');
setLoading(false);
return;
}
console.log('📝 [EDIT_USER] Mise à jour:', updates);
const result = await updateUserByAdmin(user.id, updates);
if (result.success) {
setSuccess(result.message || 'Utilisateur mis à jour avec succès');
setTimeout(() => {
onSuccess();
onClose();
}, 1500);
}
}
} catch (err) {
console.error('❌ [EDIT_MODAL] Erreur:', err);
setError(err instanceof Error ? err.message : 'Erreur lors de la mise à jour');
} finally {
setLoading(false);
}
};
return (
<>
<div className="modal-overlay" onClick={onClose} />
<div className="edit-user-modal">
<div className="modal-header">
<h2>
{isClient ? 'Modifier le client' : 'Modifier l\'utilisateur'}
</h2>
<button className="close-modal" onClick={onClose}>
<XCircle size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="modal-content">
{/* Messages de succès/erreur */}
{error && (
<div className="alert alert-error">
{error}
</div>
)}
{success && (
<div className="alert alert-success">
{success}
</div>
)}
{isClient ? (
// ============================================
// FORMULAIRE CLIENT
// ============================================
<>
<div className="form-section">
<h3>Informations personnelles</h3>
<div className="form-grid">
<div className="form-group">
<label>
<User size={16} />
<span>Nom d'utilisateur</span>
</label>
<input
type="text"
value={clientForm.username}
onChange={(e) => setClientForm({ ...clientForm, username: e.target.value })}
placeholder="Nom d'utilisateur"
/>
</div>
<div className="form-group">
<label>
<Lock size={16} />
<span>Nouveau mot de passe</span>
</label>
<input
type="password"
value={clientForm.password}
onChange={(e) => setClientForm({ ...clientForm, password: e.target.value })}
placeholder="Laisser vide pour ne pas changer"
minLength={8}
/>
</div>
<div className="form-group">
<label>
<User size={16} />
<span>Nom</span>
</label>
<input
type="text"
value={clientForm.nom}
onChange={(e) => setClientForm({ ...clientForm, nom: e.target.value })}
placeholder="Nom"
/>
</div>
<div className="form-group">
<label>
<User size={16} />
<span>Prénom</span>
</label>
<input
type="text"
value={clientForm.prenom}
onChange={(e) => setClientForm({ ...clientForm, prenom: e.target.value })}
placeholder="Prénom"
/>
</div>
<div className="form-group full-width">
<label>
<Phone size={16} />
<span>Téléphone</span>
</label>
<input
type="tel"
value={clientForm.telephone}
onChange={(e) => setClientForm({ ...clientForm, telephone: e.target.value })}
placeholder="+33612345678"
/>
</div>
</div>
</div>
<div className="form-section">
<h3>Statistiques (Admin uniquement)</h3>
<div className="form-grid">
<div className="form-group">
<label>
<span>📦</span>
<span>Commandes</span>
</label>
<input
type="number"
value={clientForm.command}
onChange={(e) => setClientForm({ ...clientForm, command: parseInt(e.target.value) || 0 })}
min="0"
/>
</div>
<div className="form-group">
<label>
<span>🍃</span>
<span>Points Weed/Hash</span>
</label>
<input
type="number"
value={clientForm.point}
onChange={(e) => setClientForm({ ...clientForm, point: parseInt(e.target.value) || 0 })}
min="0"
/>
</div>
<div className="form-group">
<label>
<span>💨</span>
<span>Points Zipette</span>
</label>
<input
type="number"
value={clientForm.points_zipette}
onChange={(e) => setClientForm({ ...clientForm, points_zipette: parseInt(e.target.value) || 0 })}
min="0"
/>
</div>
<div className="form-group">
<label>
<span>⚠️</span>
<span>Amendes</span>
</label>
<input
type="number"
step="0.01"
value={clientForm.amende}
onChange={(e) => setClientForm({ ...clientForm, amende: parseFloat(e.target.value) || 0 })}
min="0"
/>
</div>
</div>
</div>
</>
) : (
// ============================================
// FORMULAIRE USER (admin/cabine/livreur)
// ============================================
<div className="form-section">
<h3>Informations utilisateur</h3>
<div className="form-grid">
<div className="form-group">
<label>
<User size={16} />
<span>Nom d'utilisateur</span>
</label>
<input
type="text"
value={userForm.username}
onChange={(e) => setUserForm({ ...userForm, username: e.target.value })}
placeholder="Nom d'utilisateur"
/>
</div>
<div className="form-group">
<label>
<Lock size={16} />
<span>Nouveau mot de passe</span>
</label>
<input
type="password"
value={userForm.password}
onChange={(e) => setUserForm({ ...userForm, password: e.target.value })}
placeholder="Laisser vide pour ne pas changer"
minLength={8}
/>
</div>
<div className="form-group full-width">
<label>
<Shield size={16} />
<span>Rôle</span>
</label>
<div className="custom-select-wrapper">
<select
value={userForm.role}
onChange={(e) => setUserForm({ ...userForm, role: e.target.value as any })}
className="role-select"
>
<option value="admin">Administrateur</option>
<option value="cabine">Opérateur Cabine</option>
<option value="livreur">Livreur</option>
</select>
<div className="select-icon">
{userForm.role === 'admin' && <Crown size={16} />}
{userForm.role === 'cabine' && <Building2 size={16} />}
{userForm.role === 'livreur' && <Truck size={16} />}
</div>
<div className="select-arrow"></div>
</div>
</div>
</div>
</div>
)}
<div className="modal-actions">
<button
type="button"
className="action-button secondary"
onClick={onClose}
disabled={loading}
>
Annuler
</button>
<button
type="submit"
className="action-button primary"
disabled={loading}
>
{loading ? (
<>
<span className="spinner" />
<span>Enregistrement...</span>
</>
) : (
<>
<Save size={18} />
<span>Enregistrer les modifications</span>
</>
)}
</button>
</div>
</form>
</div>
</>
);
}
export default EditUserModal;
+414 -348
View File
@@ -1,419 +1,485 @@
.navbar {
/* ============================================================
Tokens
============================================================ */
:root {
--topbar-h: 60px;
--sidebar-w: 260px;
--bg: #09090b;
--surface: #111115;
--surface-2: #1c1c22;
--border: rgba(255, 255, 255, 0.07);
--primary: #8b5cf6;
--primary-soft: rgba(139, 92, 246, 0.12);
--primary-glow: rgba(139, 92, 246, 0.25);
--cyan: #22d3ee;
--cyan-soft: rgba(34, 211, 238, 0.1);
--red: #ef4444;
--text: #f4f4f5;
--text-muted: #71717a;
--radius: 10px;
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ============================================================
Body offset
============================================================ */
body {
padding-top: var(--topbar-h);
}
/* ============================================================
Top Bar
============================================================ */
.topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background-color: #1a1a1a;
border-bottom: 2px solid #333;
height: var(--topbar-h);
z-index: 900;
display: flex;
align-items: center;
padding: 0 1rem;
z-index: 100;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
padding: 0 0.75rem;
gap: 0.5rem;
background: rgba(9, 9, 11, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 24px rgba(0, 0, 0, 0.4);
}
.hamburger-menu {
.topbar-toggle {
width: 40px;
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--text-muted);
font-size: 1.1rem;
cursor: pointer;
transition: all var(--transition);
-webkit-tap-highlight-color: transparent;
}
.topbar-toggle:hover,
.topbar-toggle.is-open {
background: var(--surface-2);
border-color: var(--border);
color: var(--text);
}
.topbar-brand {
flex: 1;
text-align: center;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.02em;
background: linear-gradient(135deg, #a78bfa, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
user-select: none;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* ── Icon button (notif / cart) ── */
.topbar-icon-btn {
position: relative;
width: 40px;
height: 40px;
display: flex;
flex-direction: column;
justify-content: space-around;
padding: 8px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: all 0.3s;
}
.hamburger-menu:active {
transform: scale(0.9);
}
.hamburger-line {
width: 100%;
height: 3px;
background-color: white;
border-radius: 2px;
transition: all 0.3s ease;
}
.hamburger-line.open:nth-child(1) {
transform: rotate(45deg) translate(6px, 6px);
}
.hamburger-line.open:nth-child(2) {
opacity: 0;
}
.hamburger-line.open:nth-child(3) {
transform: rotate(-45deg) translate(6px, -6px);
}
.navbar-title {
color: white;
font-size: clamp(1.2rem, 4vw, 1.5rem);
margin: 0 0 0 1rem;
font-weight: 600;
flex: 1;
}
.cart-button {
position: relative;
background: transparent;
border: none;
width: 45px;
height: 45px;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--text-muted);
font-size: 1rem;
cursor: pointer;
transition: all var(--transition);
-webkit-tap-highlight-color: transparent;
transition: all 0.2s;
margin-left: 330px;
}
.cart-button:active {
transform: scale(0.9);
.topbar-icon-btn:hover {
background: var(--surface-2);
border-color: var(--border);
color: var(--text);
}
.cart-icon {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
.topbar-icon-btn:active {
transform: scale(0.93);
}
.cart-count {
.topbar-badge {
position: absolute;
top: 2px;
right: 2px;
background-color: #ff0000;
color: white;
font-size: 0.7rem;
font-weight: bold;
min-width: 18px;
height: 18px;
border-radius: 50%;
top: 4px;
right: 4px;
min-width: 17px;
height: 17px;
border-radius: 999px;
background: var(--red);
color: #fff;
font-size: 0.62rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
padding: 0 3px;
border: 2px solid var(--bg);
line-height: 1;
}
/* Menu latéral */
.side-menu {
/* ============================================================
Overlay
============================================================ */
.sidebar-overlay {
position: fixed;
inset: 0;
z-index: 950;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* ============================================================
Sidebar
============================================================ */
.sidebar {
position: fixed;
top: 0;
left: -280px;
width: 280px;
height: 100vh;
background-color: #0a0a0a;
border-right: 2px solid #333;
z-index: 200;
transition: left 0.3s ease;
overflow-y: auto;
left: 0;
width: var(--sidebar-w);
height: 100dvh;
z-index: 1000;
display: flex;
flex-direction: column;
background: var(--bg);
border-right: 1px solid var(--border);
transform: translateX(-100%);
transition: transform var(--transition);
overflow: hidden;
}
.side-menu.open {
left: 0;
.sidebar.open {
transform: translateX(0);
box-shadow: 4px 0 40px rgba(0, 0, 0, 0.6);
}
.side-menu-header {
/* ── Header ── */
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 1rem;
border-bottom: 2px solid #333;
justify-content: space-between;
padding: 1.25rem 1rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.side-menu-header h3 {
color: white;
margin: 0;
font-size: 1.5rem;
.sidebar-brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.close-menu {
background: transparent;
border: none;
color: white;
font-size: 2.5rem;
cursor: pointer;
width: 40px;
height: 40px;
.sidebar-brand-icon {
width: 38px;
height: 38px;
border-radius: 10px;
background: var(--primary-soft);
border: 1px solid rgba(139, 92, 246, 0.25);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
font-size: 1rem;
flex-shrink: 0;
}
.sidebar-brand-name {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: var(--text);
letter-spacing: 0.01em;
}
.sidebar-brand-sub {
margin: 0;
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 1px;
}
.sidebar-close {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
font-size: 0.85rem;
cursor: pointer;
transition: all var(--transition);
-webkit-tap-highlight-color: transparent;
transition: all 0.2s;
flex-shrink: 0;
}
.close-menu:active {
transform: scale(0.9);
.sidebar-close:hover {
background: var(--surface-2);
color: var(--text);
}
.side-menu-nav {
/* ── Nav ── */
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 0.75rem 0.75rem;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar {
display: none;
}
.menu-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
padding: 1rem 0;
flex: 1;
gap: 2px;
}
.menu-item {
color: white;
text-decoration: none;
padding: 1rem 1.5rem;
font-size: 1.1rem;
border-bottom: 1px solid #222;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.menu-item:active {
background-color: #1a1a1a;
transform: translateX(5px);
}
/* Overlay */
.menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
z-index: 150;
}
/* Désactiver hover sur tactile */
@media (hover: none) {
.menu-item:hover {
background-color: transparent;
}
}
/* Sélecteur de couleur */
.color-picker-section {
margin-top: 0;
border-top: 2px solid #333;
padding: 1rem;
background-color: #0a0a0a;
}
.color-picker-toggle {
width: 100%;
background-color: #1a1a1a;
border: 2px solid #333;
border-radius: 8px;
padding: 0.8rem 1rem;
display: flex;
align-items: center;
gap: 0.8rem;
cursor: pointer;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.color-picker-toggle:active {
transform: scale(0.98);
background-color: #222;
}
.color-preview {
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
.color-picker-toggle span {
color: white;
font-size: 1rem;
flex: 1;
}
.arrow {
color: white;
font-size: 0.8rem;
transition: transform 0.3s;
}
.arrow.up {
transform: rotate(180deg);
}
.color-options {
margin-top: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.color-option {
background-color: #0a0a0a;
border: 1px solid #333;
border-radius: 8px;
padding: 0.8rem 1rem;
display: flex;
align-items: center;
gap: 0.8rem;
cursor: pointer;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.color-option:active {
transform: scale(0.98);
background-color: #1a1a1a;
}
.color-option.active {
border-color: #4ade80;
background-color: #1a2a1a;
}
.color-circle {
width: 25px;
height: 25px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
.color-option span {
color: white;
font-size: 0.95rem;
flex: 1;
}
.check {
color: #4ade80;
font-size: 1.2rem;
font-weight: bold;
}
/* Bouton panier flottant */
.cart-button-float {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1001;
width: 56px;
height: 56px;
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.2),
rgba(109, 40, 217, 0.1)
);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 50%;
color: #7c3aed;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.cart-button-float:hover {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.3),
rgba(109, 40, 217, 0.15)
);
border-color: rgba(124, 58, 237, 0.5);
transform: scale(1.05);
}
.cart-button-float:active {
transform: scale(0.95);
}
.cart-icon-fa {
font-size: 1.5rem;
}
.cart-count {
position: absolute;
top: -4px;
right: -4px;
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
font-size: 0.75rem;
font-weight: 700;
min-width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
border: 2px solid #0f0f0f;
}
/* Bouton Telegram */
.telegram-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.9rem 1rem;
background: linear-gradient(
135deg,
rgba(37, 161, 244, 0.1),
rgba(32, 139, 220, 0.05)
);
border: 1px solid rgba(37, 161, 244, 0.3);
border-radius: 12px;
color: #25a1f4;
font-size: 0.95rem;
font-weight: 600;
gap: 0.85rem;
padding: 0.7rem 0.9rem;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 0.5rem;
text-align: left;
transition: all var(--transition);
-webkit-tap-highlight-color: transparent;
position: relative;
}
.telegram-button:hover:not(:disabled) {
background: linear-gradient(
135deg,
rgba(37, 161, 244, 0.15),
rgba(32, 139, 220, 0.08)
);
border-color: rgba(37, 161, 244, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(37, 161, 244, 0.3);
.menu-item:hover:not(:disabled) {
background: var(--surface);
border-color: var(--border);
color: var(--text);
}
.telegram-button:active:not(:disabled) {
.menu-item.active {
background: var(--primary-soft);
border-color: rgba(139, 92, 246, 0.2);
color: var(--primary);
}
.menu-item:active:not(:disabled) {
transform: scale(0.98);
}
.telegram-button:disabled {
opacity: 0.5;
.menu-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Footer styles */
.menu-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--surface);
font-size: 0.85rem;
flex-shrink: 0;
transition: all var(--transition);
}
.menu-item.active .menu-icon {
background: rgba(139, 92, 246, 0.2);
color: var(--primary);
}
.menu-label {
flex: 1;
}
.menu-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
flex-shrink: 0;
box-shadow: 0 0 8px var(--primary-glow);
}
/* ── Footer ── */
.sidebar-footer {
margin-top: auto;
padding: 1rem;
border-top: 1px solid #333;
padding: 0.75rem;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 0.5rem;
flex-shrink: 0;
}
.sidebar-footer-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 0.7rem 1rem;
border-radius: var(--radius);
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
-webkit-tap-highlight-color: transparent;
}
.sidebar-footer-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.sidebar-footer-btn.telegram {
background: var(--cyan-soft);
border: 1px solid rgba(34, 211, 238, 0.2);
color: var(--cyan);
}
.sidebar-footer-btn.telegram:hover:not(:disabled) {
background: rgba(34, 211, 238, 0.15);
border-color: rgba(34, 211, 238, 0.35);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(34, 211, 238, 0.15);
}
.sidebar-footer-btn.logout {
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.18);
color: #f87171;
}
.sidebar-footer-btn.logout:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.13);
border-color: rgba(239, 68, 68, 0.3);
transform: translateY(-1px);
}
.sidebar-footer-btn:active:not(:disabled) {
transform: scale(0.97);
}
/* ============================================================
Notification panel
============================================================ */
.notif-panel {
position: absolute;
top: calc(var(--topbar-h) - 4px);
right: 0;
width: 300px;
max-height: 380px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
animation: slideDown 0.18s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.notif-panel-header {
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--text);
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.02em;
}
.notif-empty {
padding: 1.5rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
.notif-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
max-height: 320px;
}
.notif-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--border);
transition: background var(--transition);
}
.notif-item:last-child { border-bottom: none; }
.notif-unread {
background: var(--primary-soft);
border-left: 3px solid var(--primary);
}
.notif-read { opacity: 0.55; }
.notif-message {
color: var(--text);
font-size: 0.83rem;
line-height: 1.45;
}
.notif-time {
color: var(--text-muted);
font-size: 0.72rem;
}
/* ============================================================
Responsive
============================================================ */
@media (hover: none) {
.menu-item:hover { background: transparent; border-color: transparent; color: var(--text-muted); }
.menu-item.active:hover { background: var(--primary-soft); color: var(--primary); }
.topbar-toggle:hover { background: transparent; border-color: transparent; }
.topbar-icon-btn:hover { background: transparent; border-color: transparent; }
}
+174 -135
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useCart } from "../context/CartContext";
import "./Navbar.css";
@@ -12,10 +12,12 @@ import {
faSignOutAlt,
faBars,
faTimes,
faChevronRight,
faBell,
} from "@fortawesome/free-solid-svg-icons";
import { faTelegram } from "@fortawesome/free-brands-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { getClientNotifications, markNotificationsRead } from "../api/api";
import type { ClientNotification } from "../api/api";
interface MenuItem {
id: string;
@@ -27,98 +29,98 @@ interface MenuItem {
function Navbar() {
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const [isLoggingOut, setIsLoggingOut] = useState<boolean>(false);
const [notifications, setNotifications] = useState<ClientNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showNotifPanel, setShowNotifPanel] = useState(false);
const seenKeysRef = useRef<Set<string>>(new Set());
const isFirstLoadRef = useRef(true);
const notifPanelRef = useRef<HTMLDivElement>(null);
const { cartCount } = useCart();
const navigate = useNavigate();
const location = useLocation();
const fetchNotifications = useCallback(async () => {
const res = await getClientNotifications();
if (!res.success) return;
setNotifications(res.notifications);
setUnreadCount(res.unread_count);
if (!isFirstLoadRef.current) {
for (const n of res.notifications) {
if (n.read) continue;
const key = `${n.command_id}-${n.type}-${n.created_at}`;
if (!seenKeysRef.current.has(key)) {
seenKeysRef.current.add(key);
}
}
} else {
for (const n of res.notifications) {
seenKeysRef.current.add(`${n.command_id}-${n.type}-${n.created_at}`);
}
isFirstLoadRef.current = false;
}
}, []);
useEffect(() => {
fetchNotifications();
const interval = setInterval(fetchNotifications, 15000);
return () => clearInterval(interval);
}, [fetchNotifications]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (notifPanelRef.current && !notifPanelRef.current.contains(e.target as Node)) {
setShowNotifPanel(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleNotifBellClick = async () => {
setShowNotifPanel((prev) => !prev);
if (!showNotifPanel && unreadCount > 0) {
await markNotificationsRead();
setUnreadCount(0);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}
};
const menuItems: MenuItem[] = [
{
id: "accueil",
label: "Accueil",
icon: faHome,
path: "/user/accueil",
},
{
id: "produits",
label: "Nos Produits",
icon: faBox,
path: "/user/nos-produits",
},
{
id: "panier",
label: "Passer Commande",
icon: faShoppingCart,
path: "/user/panier",
},
{
id: "suivi",
label: "Suivi Livraison",
icon: faTruck,
path: "/user/suivi-livraison",
},
{
id: "historique",
label: "Historique",
icon: faClockRotateLeft,
path: "/user/consultation-historique",
},
{ id: "accueil", label: "Accueil", icon: faHome, path: "/user/accueil" },
{ id: "produits", label: "Nos Produits", icon: faBox, path: "/user/nos-produits" },
{ id: "panier", label: "Mon Panier", icon: faShoppingCart, path: "/user/panier" },
{ id: "suivi", label: "Suivi Livraison", icon: faTruck, path: "/user/suivi-livraison" },
{ id: "historique", label: "Historique", icon: faClockRotateLeft, path: "/user/consultation-historique" },
];
const toggleMenu = (): void => {
setIsMenuOpen(!isMenuOpen);
};
const toggleMenu = () => setIsMenuOpen((v) => !v);
const closeMenu = () => setIsMenuOpen(false);
const closeMenu = (): void => {
setIsMenuOpen(false);
};
const handleNavigation = (path: string): void => {
const handleNavigation = (path: string) => {
navigate(path);
closeMenu();
};
const handleLogout = async (): Promise<void> => {
const handleLogout = async () => {
if (isLoggingOut) return;
setIsLoggingOut(true);
console.log("🚪 [LOGOUT] Déconnexion en cours...");
try {
const token = sessionStorage.getItem("admin_token");
if (token) {
try {
const response = await fetch(
"http://localhost:8080/api/v2/admin/auth/logout",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
await fetch("/api/v2/admin/auth/logout", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
);
if (response.ok) {
console.log("✅ [LOGOUT] Déconnexion backend réussie");
} else {
console.warn("⚠️ [LOGOUT] Erreur backend (ignorée)");
}
} catch (error) {
console.warn(
"⚠️ [LOGOUT] Erreur réseau backend (ignorée):",
error,
);
}
});
} catch (_) {}
}
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
console.log("✅ [LOGOUT] SessionStorage nettoyé");
navigate("/login/client", { replace: true });
} catch (error) {
console.error("❌ [LOGOUT] Erreur:", error);
} catch (_) {
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login/client", { replace: true });
@@ -127,86 +129,125 @@ function Navbar() {
}
};
const handleTelegram = (): void => {
window.open("https://t.me/milieu_nantais", "_blank");
};
const handleTelegram = () => window.open("https://t.me/milieu_nantais", "_blank");
return (
<>
{/* Bouton Toggle */}
<button
className={`sidebar-toggle ${isMenuOpen ? "sidebar-open" : ""}`}
onClick={toggleMenu}
aria-label="Toggle menu"
>
<FontAwesomeIcon icon={isMenuOpen ? faTimes : faBars} />
</button>
{/* ── Top Bar ─────────────────────────────────── */}
<header className="topbar">
<button
className={`topbar-toggle ${isMenuOpen ? "is-open" : ""}`}
onClick={toggleMenu}
aria-label="Menu"
>
<FontAwesomeIcon icon={isMenuOpen ? faTimes : faBars} />
</button>
{/* Overlay */}
{isMenuOpen && (
<div className="sidebar-overlay" onClick={closeMenu} />
)}
<span className="topbar-brand">MilieuNantais</span>
{/* Bouton Panier (flottant en haut à droite) */}
<button
className="cart-button-float"
onClick={() => navigate("/user/panier")}
aria-label="Panier"
>
<FontAwesomeIcon
icon={faShoppingCart}
className="cart-icon-fa"
/>
{cartCount > 0 && (
<span className="cart-count">{cartCount}</span>
)}
</button>
<div className="topbar-actions">
{/* Notifications */}
<div className="notif-wrapper" ref={notifPanelRef}>
<button
className="topbar-icon-btn"
onClick={handleNotifBellClick}
aria-label="Notifications"
>
<FontAwesomeIcon icon={faBell} />
{unreadCount > 0 && (
<span className="topbar-badge">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{/* Sidebar */}
{showNotifPanel && (
<div className="notif-panel">
<div className="notif-panel-header">Notifications</div>
{notifications.length === 0 ? (
<div className="notif-empty">Aucune notification</div>
) : (
<ul className="notif-list">
{notifications.map((n, i) => (
<li
key={i}
className={`notif-item ${n.read ? "notif-read" : "notif-unread"}`}
>
<span className="notif-message">{n.message}</span>
<span className="notif-time">
{new Date(n.created_at).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
{/* Panier */}
<button
className="topbar-icon-btn"
onClick={() => navigate("/user/panier")}
aria-label="Panier"
>
<FontAwesomeIcon icon={faShoppingCart} />
{cartCount > 0 && (
<span className="topbar-badge">{cartCount}</span>
)}
</button>
</div>
</header>
{/* ── Overlay ─────────────────────────────────── */}
{isMenuOpen && <div className="sidebar-overlay" onClick={closeMenu} />}
{/* ── Sidebar ─────────────────────────────────── */}
<aside className={`sidebar ${isMenuOpen ? "open" : ""}`}>
{/* Header */}
<div className="sidebar-header">
<div className="sidebar-logo">
<div className="logo-icon">
<FontAwesomeIcon icon={faShoppingCart} size="lg" />
<div className="sidebar-brand">
<div className="sidebar-brand-icon">
<FontAwesomeIcon icon={faShoppingCart} />
</div>
<div className="logo-text">
<h2>Milieu-Nantais</h2>
<p>User Panel</p>
<div>
<p className="sidebar-brand-name">Milieu-Nantais</p>
<p className="sidebar-brand-sub">Mon espace</p>
</div>
</div>
<button className="sidebar-close" onClick={closeMenu} aria-label="Fermer">
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
{/* Navigation Menu */}
<nav className="sidebar-nav">
<ul className="menu-list">
{menuItems.map((item) => (
<li key={item.id}>
<button
className={`menu-item ${location.pathname === item.path ? "active" : ""}`}
onClick={() => handleNavigation(item.path)}
disabled={isLoggingOut}
>
<span className="menu-icon">
<FontAwesomeIcon icon={item.icon} />
</span>
<span className="menu-label">
{item.label}
</span>
<FontAwesomeIcon
icon={faChevronRight}
className="menu-arrow"
/>
</button>
</li>
))}
{menuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<li key={item.id}>
<button
className={`menu-item ${isActive ? "active" : ""}`}
onClick={() => handleNavigation(item.path)}
disabled={isLoggingOut}
>
<span className="menu-icon">
<FontAwesomeIcon icon={item.icon} />
</span>
<span className="menu-label">{item.label}</span>
{isActive && <span className="menu-dot" />}
</button>
</li>
);
})}
</ul>
</nav>
{/* Footer avec boutons Telegram et déconnexion */}
<div className="sidebar-footer">
<button
className="telegram-button"
className="sidebar-footer-btn telegram"
onClick={handleTelegram}
disabled={isLoggingOut}
>
@@ -214,14 +255,12 @@ function Navbar() {
<span>Telegram</span>
</button>
<button
className="logout-button"
className="sidebar-footer-btn logout"
onClick={handleLogout}
disabled={isLoggingOut}
>
<FontAwesomeIcon icon={faSignOutAlt} />
<span>
{isLoggingOut ? "Déconnexion..." : "Déconnexion"}
</span>
<span>{isLoggingOut ? "Déconnexion…" : "Déconnexion"}</span>
</button>
</div>
</aside>
@@ -1,340 +0,0 @@
// ============================================
// components/admin/ProductCreateModal.tsx - VERSION AVEC BOUTONS
// ============================================
// ✅ Boutons stylisés au lieu de select natif (comme AdminUsers)
// ✅ Pas de hover gris natif !
import React, { useState } from 'react';
import { X, Plus, Trash2, Upload, DollarSign } from 'lucide-react';
import { createProductAdmin } from '../api/api_admin';
import type { CreateProductData, ProductPrice} from '../api/api_admin_types';
import './ProductModal.css';
interface ProductCreateModalProps {
onClose: () => void;
onSuccess: () => void;
}
const ProductCreateModal: React.FC<ProductCreateModalProps> = ({ onClose, onSuccess }) => {
// ============================================
// 📝 STATE
// ============================================
const [name, setName] = useState('');
const [category, setCategory] = useState('weed&hash');
const [description, setDescription] = useState('');
const [stock, setStock] = useState<number>(0);
const [prices, setPrices] = useState<ProductPrice[]>([
{ quantity: 1, price: 0 }
]);
const [mediaFiles, setMediaFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ✅ Options de catégories
const categoryOptions = [
{ value: 'weed&hash', label: 'Weed & Hash' },
{ value: 'zipette&co', label: 'Zipette & Co' },
{ value: 'gros&semi', label: 'Gros & Semi' }
];
// ============================================
// 💰 GESTION DES PRIX
// ============================================
const addPriceRow = () => {
setPrices([...prices, { quantity: 1, price: 0 }]);
};
const removePriceRow = (index: number) => {
if (prices.length > 1) {
setPrices(prices.filter((_, i) => i !== index));
}
};
const updatePrice = (index: number, field: 'quantity' | 'price', value: number) => {
const newPrices = [...prices];
newPrices[index][field] = value;
setPrices(newPrices);
};
// ============================================
// 📁 GESTION DES FICHIERS
// ============================================
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const newFiles = Array.from(e.target.files);
setMediaFiles([...mediaFiles, ...newFiles]);
console.log(`📁 ${newFiles.length} fichier(s) ajouté(s)`);
}
};
const removeFile = (index: number) => {
setMediaFiles(mediaFiles.filter((_, i) => i !== index));
};
// ============================================
// ✅ VALIDATION
// ============================================
const validateForm = (): string | null => {
if (!name.trim()) return 'Le nom est requis';
if (!description.trim()) return 'La description est requise';
if (stock < 0) return 'Le stock ne peut pas être négatif';
if (prices.length === 0) return 'Au moins un prix est requis';
for (const price of prices) {
if (price.quantity <= 0) return 'Toutes les quantités doivent être positives';
if (price.price <= 0) return 'Tous les prix doivent être positifs';
}
return null;
};
// ============================================
// 📤 SOUMISSION
// ============================================
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
try {
setLoading(true);
setError(null);
console.log('📤 [CREATE_MODAL] Envoi du formulaire...');
const productData: CreateProductData = {
name: name.trim(),
category,
description: description.trim(),
stock,
prices,
};
const response = await createProductAdmin(productData, mediaFiles);
if (response.success) {
console.log('✅ [CREATE_MODAL] Produit créé avec succès');
onSuccess();
} else {
setError(response.error || 'Erreur lors de la création du produit');
}
} catch (error) {
console.error('❌ [CREATE_MODAL] Erreur:', error);
setError(error instanceof Error ? error.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
// ============================================
// 🎨 RENDER
// ============================================
return (
<>
<div className="modal-overlay" onClick={onClose} />
<div className="product-modal">
{/* Header */}
<div className="modal-header">
<h2>Créer un Nouveau Produit</h2>
<button className="close-modal" onClick={onClose}>
<X size={24} />
</button>
</div>
{/* Content */}
<form onSubmit={handleSubmit} className="modal-content">
{error && (
<div className="error-message">
<p>{error}</p>
</div>
)}
{/* Informations de base */}
<div className="form-section">
<h3>Informations de Base</h3>
<div className="form-group">
<label htmlFor="name">Nom du Produit *</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex: OG Kush Premium"
required
/>
</div>
{/* ✅ CATÉGORIE AVEC BOUTONS AU LIEU DE SELECT */}
<div className="form-group">
<label>Catégorie *</label>
<div className="filter-buttons">
{categoryOptions.map(option => (
<button
key={option.value}
type="button"
className={category === option.value ? 'active' : ''}
onClick={() => setCategory(option.value)}
disabled={loading}
>
{option.label}
</button>
))}
</div>
</div>
<div className="form-group">
<label htmlFor="description">Description *</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Décrivez le produit en détail..."
rows={4}
required
/>
</div>
<div className="form-group">
<label htmlFor="stock">Stock (en grammes) *</label>
<input
id="stock"
type="number"
min="0"
step="0.1"
value={stock}
onChange={(e) => setStock(parseFloat(e.target.value))}
placeholder="100"
required
/>
</div>
</div>
{/* Prix */}
<div className="form-section">
<div className="section-header">
<h3>Tarifs</h3>
<button type="button" className="add-price-btn" onClick={addPriceRow}>
<Plus size={18} />
Ajouter un Prix
</button>
</div>
<div className="prices-grid">
{prices.map((price, index) => (
<div key={index} className="price-row">
<div className="price-input-group">
<label>Quantité (g)</label>
<input
type="number"
min="1"
step="1"
value={price.quantity}
onChange={(e) => updatePrice(index, 'quantity', parseInt(e.target.value))}
required
/>
</div>
<div className="price-input-group">
<label>Prix ()</label>
<div className="price-input-wrapper">
<DollarSign size={18} />
<input
type="number"
min="0"
step="0.01"
value={price.price}
onChange={(e) => updatePrice(index, 'price', parseFloat(e.target.value))}
required
/>
</div>
</div>
{prices.length > 1 && (
<button
type="button"
className="remove-price-btn"
onClick={() => removePriceRow(index)}
title="Supprimer ce prix"
>
<Trash2 size={18} />
</button>
)}
</div>
))}
</div>
</div>
{/* Médias */}
<div className="form-section">
<h3>Médias (Images & Vidéos)</h3>
<div className="upload-zone">
<label htmlFor="media-upload" className="upload-label">
<Upload size={32} />
<p>Cliquez pour ajouter des fichiers</p>
<span>Images (JPG, PNG, WebP) ou Vidéos (MP4, WebM)</span>
</label>
<input
id="media-upload"
type="file"
accept="image/*,video/*"
multiple
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
</div>
{mediaFiles.length > 0 && (
<div className="files-list">
{mediaFiles.map((file, index) => (
<div key={index} className="file-item">
<div className="file-info">
<span className="file-name">{file.name}</span>
<span className="file-size">
{(file.size / 1024).toFixed(1)} KB
</span>
</div>
<button
type="button"
className="remove-file-btn"
onClick={() => removeFile(index)}
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</div>
</form>
{/* Footer */}
<div className="modal-actions">
<button
type="button"
className="action-button secondary"
onClick={onClose}
disabled={loading}
>
Annuler
</button>
<button
type="submit"
className="action-button primary"
onClick={handleSubmit}
disabled={loading}
>
{loading ? 'Création...' : 'Créer le Produit'}
</button>
</div>
</div>
</>
);
};
export default ProductCreateModal;
@@ -18,7 +18,7 @@ import {
Video as VideoIcon,
Play,
} from "lucide-react";
import type { Product } from "../api/api_admin_types"; // ✅ CORRIGÉ
import type { Product, ProductPrice } from "../api/api_types";
import "./ProductModal.css";
interface ProductDetailsModalProps {
@@ -169,7 +169,7 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
{/* Miniatures */}
{product.media && product.media.length > 1 && (
<div className="media-thumbnails">
{product.media.map((media, index) => (
{product.media.map((media: { url: string; type: string }, index: number) => (
<button
key={index}
className={`thumbnail ${index === currentMediaIndex ? "active" : ""}`}
@@ -247,7 +247,7 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
</h3>
<div className="prices-table">
{product.prices && product.prices.length > 0 ? (
product.prices.map((price, index) => (
product.prices.map((price: ProductPrice, index: number) => (
<div
key={index}
className="price-row-display"
@@ -286,12 +286,12 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
<span>
{
product.media.filter(
(m) => m.type === "image",
(m: { url: string; type: string }) => m.type === "image",
).length
}{" "}
Image
{product.media.filter(
(m) => m.type === "image",
(m: { url: string; type: string }) => m.type === "image",
).length > 1
? "s"
: ""}
@@ -302,12 +302,12 @@ const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
<span>
{
product.media.filter(
(m) => m.type === "video",
(m: { url: string; type: string }) => m.type === "video",
).length
}{" "}
Vidéo
{product.media.filter(
(m) => m.type === "video",
(m: { url: string; type: string }) => m.type === "video",
).length > 1
? "s"
: ""}
@@ -1,550 +0,0 @@
// ============================================
// components/admin/ProductEditModal.tsx
// ============================================
// ✅ Gestion correcte de la mise à jour des produits
// ✅ Envoi en JSON (pas FormData)
// ✅ Upload séparé des médias
// ✅ Boutons au lieu de select (comme AdminUsers)
import React, { useState } from "react";
import {
X,
Plus,
Trash2,
Upload,
DollarSign,
Image as ImageIcon,
} from "lucide-react";
import {
updateProductAdmin,
deleteProductMediaAdmin,
uploadProductMediaAdmin,
} from "../api/api_admin";
import type { Product, ProductPrice } from "../api/api_admin_types";
import "./ProductModal.css";
interface ProductEditModalProps {
product: Product;
onClose: () => void;
onSuccess: () => void;
}
const ProductEditModal: React.FC<ProductEditModalProps> = ({
product,
onClose,
onSuccess,
}) => {
// ============================================
// 📝 STATE
// ============================================
const [name, setName] = useState(product.name);
const [category, setCategory] = useState(product.category);
const [description, setDescription] = useState(product.description);
const [stock, setStock] = useState(product.stock);
const [prices, setPrices] = useState<ProductPrice[]>(
product.prices && product.prices.length > 0
? product.prices
: [{ quantity: 1, price: 0 }],
);
const [existingMedia, setExistingMedia] = useState(product.media || []);
const [newMediaFiles, setNewMediaFiles] = useState<File[]>([]);
const [mediaToDelete, setMediaToDelete] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ✅ Options de catégories
const categoryOptions = [
{ value: "weed&hash", label: "Weed & Hash" },
{ value: "zipette&co", label: "Zipette & Co" },
{ value: "gros&semi", label: "Gros & Semi" },
];
// ============================================
// 💰 GESTION DES PRIX
// ============================================
const addPriceRow = () => {
setPrices([...prices, { quantity: 1, price: 0 }]);
};
const removePriceRow = (index: number) => {
if (prices.length > 1) {
setPrices(prices.filter((_, i) => i !== index));
}
};
const updatePrice = (
index: number,
field: "quantity" | "price",
value: number,
) => {
const newPrices = [...prices];
newPrices[index][field] = value;
setPrices(newPrices);
};
// ============================================
// 📁 GESTION DES MÉDIAS
// ============================================
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const newFiles = Array.from(e.target.files);
setNewMediaFiles([...newMediaFiles, ...newFiles]);
console.log(
`📁 ${newFiles.length} nouveau(x) fichier(s) ajouté(s)`,
);
}
};
const removeNewFile = (index: number) => {
setNewMediaFiles(newMediaFiles.filter((_, i) => i !== index));
};
const markMediaForDeletion = (mediaId: number) => {
setMediaToDelete([...mediaToDelete, mediaId]);
setExistingMedia(existingMedia.filter((m) => m.id !== mediaId));
console.log(`🗑️ Média ${mediaId} marqué pour suppression`);
};
// ============================================
// ✅ VALIDATION
// ============================================
const validateForm = (): string | null => {
if (!name.trim()) return "Le nom est requis";
if (!description.trim()) return "La description est requise";
if (stock < 0) return "Le stock ne peut pas être négatif";
if (prices.length === 0) return "Au moins un prix est requis";
for (const price of prices) {
if (price.quantity <= 0)
return "Toutes les quantités doivent être positives";
if (price.price <= 0) return "Tous les prix doivent être positifs";
}
return null;
};
// ============================================
// 📤 SOUMISSION - VERSION CORRIGÉE
// ============================================
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
if (!product.id) {
setError("ID du produit manquant");
return;
}
try {
setLoading(true);
setError(null);
console.log(
"📤 [EDIT_MODAL] Début de la mise à jour du produit:",
product.id,
);
// ============================================
// ÉTAPE 1: Supprimer les médias marqués
// ============================================
if (mediaToDelete.length > 0) {
console.log(
`🗑️ Suppression de ${mediaToDelete.length} média(s)...`,
);
for (const mediaId of mediaToDelete) {
try {
await deleteProductMediaAdmin(product.id, mediaId);
console.log(`✅ Média ${mediaId} supprimé`);
} catch (err) {
console.warn(
`⚠️ Erreur suppression média ${mediaId}:`,
err,
);
}
}
}
// ============================================
// ÉTAPE 2: Préparer les données de mise à jour (JSON)
// ============================================
const updateData: Partial<Product> = {
name: name.trim(),
category,
description: description.trim(),
stock,
prices: prices.map((p) => ({
quantity: p.quantity,
price: p.price,
})),
};
console.log("📝 [EDIT_MODAL] Données de mise à jour:", updateData);
// ============================================
// ÉTAPE 3: Mettre à jour le produit
// ============================================
const response = await updateProductAdmin(product.id, updateData);
if (!response.success) {
console.error("❌ [EDIT_MODAL] Erreur API:", response.error);
setError(
response.error ||
"Erreur lors de la mise à jour du produit",
);
setLoading(false);
return;
}
console.log("✅ [EDIT_MODAL] Produit mis à jour avec succès");
// ============================================
// ÉTAPE 4: Upload des nouveaux médias
// ============================================
if (newMediaFiles.length > 0) {
console.log(
`📤 [EDIT_MODAL] Upload de ${newMediaFiles.length} nouveau(x) média(s)...`,
);
for (const file of newMediaFiles) {
try {
const fileType = file.type.startsWith("image/")
? "image"
: "video";
const uploadResponse = await uploadProductMediaAdmin(
product.id,
file,
fileType,
);
if (uploadResponse.success) {
console.log(`✅ Média uploadé: ${file.name}`);
} else {
console.warn(
`⚠️ Erreur upload ${file.name}:`,
uploadResponse.error,
);
}
} catch (err) {
console.warn(`⚠️ Erreur upload ${file.name}:`, err);
}
}
}
console.log("🎉 [EDIT_MODAL] Toutes les modifications appliquées");
// Fermer et rafraîchir
onSuccess();
} catch (error) {
console.error("❌ [EDIT_MODAL] Erreur:", error);
setError(
error instanceof Error ? error.message : "Erreur inconnue",
);
} finally {
setLoading(false);
}
};
// ============================================
// 🎨 RENDER
// ============================================
return (
<>
<div className="modal-overlay" onClick={onClose} />
<div className="product-modal edit-modal">
{/* Header */}
<div className="modal-header">
<h2>Modifier le Produit</h2>
<button
className="close-modal"
onClick={onClose}
disabled={loading}
>
<X size={24} />
</button>
</div>
{/* Content */}
<form onSubmit={handleSubmit} className="modal-content">
{error && (
<div className="error-message">
<p>{error}</p>
</div>
)}
{/* Informations de base */}
<div className="form-section">
<h3>Informations de Base</h3>
<div className="form-group">
<label htmlFor="name">Nom du Produit *</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex: OG Kush Premium"
disabled={loading}
required
/>
</div>
{/* ✅ CATÉGORIE AVEC BOUTONS AU LIEU DE SELECT */}
<div className="form-group">
<label>Catégorie *</label>
<div className="filter-buttons">
{categoryOptions.map((option) => (
<button
key={option.value}
type="button"
className={
category === option.value
? "active"
: ""
}
onClick={() =>
setCategory(option.value)
}
disabled={loading}
>
{option.label}
</button>
))}
</div>
</div>
<div className="form-group">
<label htmlFor="description">Description *</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Décrivez le produit en détail..."
rows={4}
disabled={loading}
required
/>
</div>
<div className="form-group">
<label htmlFor="stock">Stock (en grammes) *</label>
<input
id="stock"
type="number"
min="0"
step="0.1"
value={stock}
onChange={(e) =>
setStock(parseFloat(e.target.value) || 0)
}
placeholder="100"
disabled={loading}
required
/>
</div>
</div>
{/* Prix */}
<div className="form-section">
<div className="section-header">
<h3>Tarifs</h3>
<button
type="button"
className="add-price-btn"
onClick={addPriceRow}
disabled={loading}
>
<Plus size={18} />
Ajouter un Prix
</button>
</div>
<div className="prices-grid">
{prices.map((price, index) => (
<div key={index} className="price-row">
<div className="price-input-group">
<label>Quantité (g)</label>
<input
type="number"
min="1"
step="1"
value={price.quantity}
onChange={(e) =>
updatePrice(
index,
"quantity",
parseInt(e.target.value) ||
1,
)
}
disabled={loading}
required
/>
</div>
<div className="price-input-group">
<label>Prix ()</label>
<div className="price-input-wrapper">
<DollarSign size={18} />
<input
type="number"
min="0"
step="0.01"
value={price.price}
onChange={(e) =>
updatePrice(
index,
"price",
parseFloat(
e.target.value,
) || 0,
)
}
disabled={loading}
required
/>
</div>
</div>
{prices.length > 1 && (
<button
type="button"
className="remove-price-btn"
onClick={() =>
removePriceRow(index)
}
disabled={loading}
title="Supprimer ce prix"
>
<Trash2 size={18} />
</button>
)}
</div>
))}
</div>
</div>
{/* Médias existants */}
{existingMedia.length > 0 && (
<div className="form-section">
<h3>Médias Actuels</h3>
<div className="existing-media-grid">
{existingMedia.map((media) => (
<div
key={media.id}
className="existing-media-item"
>
{media.type === "image" ? (
<img
src={`${media.url}`}
alt="Media"
className="media-thumbnail"
/>
) : (
<div className="video-thumbnail">
<ImageIcon size={32} />
<span>Vidéo</span>
</div>
)}
<button
type="button"
className="delete-media-btn"
onClick={() =>
markMediaForDeletion(media.id!)
}
disabled={loading}
title="Supprimer ce média"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
</div>
)}
{/* Nouveaux médias */}
<div className="form-section">
<h3>Ajouter de Nouveaux Médias</h3>
<div className="upload-zone">
<label
htmlFor="media-upload"
className="upload-label"
>
<Upload size={32} />
<p>Cliquez pour ajouter des fichiers</p>
<span>
Images (JPG, PNG, WebP) ou Vidéos (MP4,
WebM)
</span>
</label>
<input
id="media-upload"
type="file"
accept="image/*,video/*"
multiple
onChange={handleFileSelect}
disabled={loading}
style={{ display: "none" }}
/>
</div>
{newMediaFiles.length > 0 && (
<div className="files-list">
{newMediaFiles.map((file, index) => (
<div key={index} className="file-item">
<div className="file-info">
<span className="file-name">
{file.name}
</span>
<span className="file-size">
{(file.size / 1024).toFixed(1)}{" "}
KB
</span>
</div>
<button
type="button"
className="remove-file-btn"
onClick={() => removeNewFile(index)}
disabled={loading}
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</div>
</form>
{/* Footer */}
<div className="modal-actions">
<button
type="button"
className="action-button secondary"
onClick={onClose}
disabled={loading}
>
Annuler
</button>
<button
type="submit"
className="action-button primary"
onClick={handleSubmit}
disabled={loading}
>
{loading ? "Mise à jour..." : "Mettre à Jour"}
</button>
</div>
</div>
</>
);
};
export default ProductEditModal;
-477
View File
@@ -1,477 +0,0 @@
/* Sidebar Toggle Button */
.sidebar-toggle {
position: fixed;
top: 1rem;
left: 1rem;
z-index: 1001;
width: 48px;
height: 48px;
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.2),
rgba(109, 40, 217, 0.1)
);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 12px;
color: #7c3aed;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.sidebar-toggle.sidebar-open {
left: calc(280px + 1rem);
}
.sidebar-toggle:hover {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.3),
rgba(109, 40, 217, 0.15)
);
border-color: rgba(124, 58, 237, 0.5);
transform: scale(1.05);
}
.sidebar-toggle:active {
transform: scale(0.95);
}
/* Overlay */
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
z-index: 999;
backdrop-filter: blur(4px);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Sidebar Container */
.sidebar {
position: fixed;
left: 0;
top: 0;
height: 100vh;
width: 280px;
background: linear-gradient(180deg, #0f0f0f 0%, #1a1a1a 100%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar::-webkit-scrollbar {
width: 6px;
}
.sidebar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
}
.sidebar::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.3);
border-radius: 3px;
}
.sidebar::-webkit-scrollbar-thumb:hover {
background: rgba(124, 58, 237, 0.5);
}
/* Sidebar Header */
.sidebar-header {
padding: 2rem 1.5rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 1rem;
}
.logo-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #7c3aed, #6d28d9);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
flex-shrink: 0;
}
.logo-text h2 {
color: white;
font-size: 1.3rem;
margin: 0;
font-weight: bold;
letter-spacing: -0.5px;
}
.logo-text p {
color: #888;
font-size: 0.75rem;
margin: 0.2rem 0 0 0;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Navigation */
.sidebar-nav {
flex: 1;
padding: 1.5rem 0;
overflow-y: auto;
}
.menu-list {
list-style: none;
padding: 0;
margin: 0;
}
.menu-list li {
margin-bottom: 0.5rem;
padding: 0 1rem;
}
.menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.9rem 1rem;
background: transparent;
border: 1px solid transparent;
border-radius: 12px;
color: #888;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
text-align: left;
}
.menu-item::before {
content: "";
position: absolute;
left: -100%;
top: 0;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(124, 58, 237, 0.1),
transparent
);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-item:hover::before {
left: 100%;
}
.menu-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-label {
flex: 1;
}
.menu-badge {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
font-size: 0.75rem;
font-weight: 700;
padding: 0.2rem 0.6rem;
border-radius: 20px;
min-width: 24px;
text-align: center;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
}
.menu-arrow {
flex-shrink: 0;
opacity: 0;
transform: translateX(-8px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-item:hover {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.1),
rgba(109, 40, 217, 0.05)
);
border-color: rgba(124, 58, 237, 0.2);
color: white;
transform: translateX(4px);
}
.menu-item:hover .menu-icon {
transform: scale(1.1);
color: #7c3aed;
}
.menu-item:hover .menu-arrow {
opacity: 1;
transform: translateX(0);
color: #7c3aed;
}
.menu-item.active {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.15),
rgba(109, 40, 217, 0.08)
);
border-color: rgba(124, 58, 237, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.menu-item.active .menu-icon {
color: #7c3aed;
}
.menu-item.active .menu-arrow {
opacity: 1;
transform: translateX(0);
color: #7c3aed;
}
.menu-item:active {
transform: scale(0.98) translateX(4px);
}
/* Sidebar Footer */
.sidebar-footer {
padding: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin-top: auto;
}
.user-profile {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03),
rgba(255, 255, 255, 0.01)
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
margin-bottom: 1rem;
}
.user-avatar {
width: 44px;
height: 44px;
background: linear-gradient(135deg, #10b981, #059669);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 0.95rem;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
color: white;
font-size: 0.95rem;
font-weight: 600;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
color: #888;
font-size: 0.8rem;
margin: 0.2rem 0 0 0;
}
.logout-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.9rem 1rem;
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.1),
rgba(220, 38, 38, 0.05)
);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 12px;
color: #ef4444;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.logout-button:hover {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.15),
rgba(220, 38, 38, 0.08)
);
border-color: rgba(239, 68, 68, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.logout-button:active {
transform: scale(0.98);
}
/* Bouton Telegram */
.telegram-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.9rem 1rem;
background: linear-gradient(
135deg,
rgba(37, 161, 244, 0.1),
rgba(32, 139, 220, 0.05)
);
border: 1px solid rgba(37, 161, 244, 0.3);
border-radius: 12px;
color: #25a1f4;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 0.5rem;
}
.telegram-button:hover:not(:disabled) {
background: linear-gradient(
135deg,
rgba(37, 161, 244, 0.15),
rgba(32, 139, 220, 0.08)
);
border-color: rgba(37, 161, 244, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(37, 161, 244, 0.3);
}
.telegram-button:active:not(:disabled) {
transform: scale(0.98);
}
.telegram-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 280px;
}
.sidebar-toggle.sidebar-open {
left: calc(280px + 1rem);
}
}
@media (max-width: 480px) {
.sidebar {
width: 260px;
}
.sidebar-toggle.sidebar-open {
left: calc(260px + 1rem);
}
.sidebar-header {
padding: 1.5rem 1rem 1rem;
}
.logo-text h2 {
font-size: 1.1rem;
}
.menu-list li {
padding: 0 0.5rem;
}
.menu-item {
padding: 0.8rem 0.8rem;
font-size: 0.9rem;
}
.sidebar-footer {
padding: 1rem;
}
}
@media (hover: none) {
.menu-item:hover {
transform: translateX(0);
}
.menu-item:hover .menu-icon {
transform: none;
}
.sidebar-toggle:hover {
transform: none;
}
.logout-button:hover {
transform: none;
}
}
-232
View File
@@ -1,232 +0,0 @@
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import "./Sidebar.css";
// Importation de Font Awesome
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChartLine,
faShoppingCart,
faBox,
faUsers,
faSignOutAlt,
faBars,
faTimes,
faChevronRight,
faBoxOpen,
faMotorcycle,
faExclamationCircle,
} from "@fortawesome/free-solid-svg-icons";
import { faTelegram } from "@fortawesome/free-brands-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
interface MenuItem {
id: string;
label: string;
icon: IconDefinition;
path: string;
badge?: number;
}
function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const [isOpen, setIsOpen] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const menuItems: MenuItem[] = [
{
id: "dashboard",
label: "Tableau de Bord",
icon: faChartLine,
path: "/admin/dashboard",
},
{
id: "orders",
label: "Commandes",
icon: faShoppingCart,
path: "/admin/dashboard/orders",
},
{
id: "products",
label: "Produits",
icon: faBox,
path: "/admin/dashboard/products",
},
{
id: "delivery-persons",
label: "Livreurs",
icon: faMotorcycle,
path: "/admin/dashboard/delivery",
},
{
id: "users",
label: "Utilisateurs",
icon: faUsers,
path: "/admin/dashboard/users",
},
{
id: "alerts",
label: "Alertes",
icon: faExclamationCircle,
path: "/admin/dashboard/alerts",
},
];
const handleNavigation = (path: string) => {
navigate(path);
setIsOpen(false);
};
const handleLogout = async () => {
if (isLoggingOut) return; // Éviter les double-clics
setIsLoggingOut(true);
console.log("🚪 [LOGOUT] Déconnexion en cours...");
try {
const token = sessionStorage.getItem("admin_token");
if (token) {
// ✅ Appel API de logout (via admin API car cabine utilise le même token)
try {
const response = await fetch(
"http://localhost:8080/api/v2/admin/auth/logout",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (response.ok) {
console.log("✅ [LOGOUT] Déconnexion backend réussie");
} else {
console.warn("⚠️ [LOGOUT] Erreur backend (ignorée)");
}
} catch (error) {
console.warn(
"⚠️ [LOGOUT] Erreur réseau backend (ignorée):",
error,
);
}
}
// ✅ CRITIQUE: Nettoyer sessionStorage
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
console.log("✅ [LOGOUT] SessionStorage nettoyé");
navigate("/login-admin/admin", { replace: true });
} catch (error) {
console.error("❌ [LOGOUT] Erreur:", error);
// ✅ Même en cas d'erreur, nettoyer et rediriger
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-admin/admin", { replace: true });
} finally {
setIsLoggingOut(false);
}
};
const handleTelegram = () => {
window.open("https://t.me/milieu_nantais", "_blank");
};
const toggleSidebar = () => {
setIsOpen(!isOpen);
};
return (
<>
{/* Menu Toggle Button */}
<button
className={`sidebar-toggle ${isOpen ? "sidebar-open" : ""}`}
onClick={toggleSidebar}
>
<FontAwesomeIcon icon={isOpen ? faTimes : faBars} />
</button>
{/* Overlay */}
{isOpen && (
<div
className="sidebar-overlay"
onClick={() => setIsOpen(false)}
/>
)}
{/* Sidebar */}
<aside className={`sidebar ${isOpen ? "open" : ""}`}>
{/* Logo / Brand */}
<div className="sidebar-header">
<div className="sidebar-logo">
<div className="logo-icon">
<FontAwesomeIcon icon={faBoxOpen} size="lg" />
</div>
<div className="logo-text">
<h2>Milieu-Nantais</h2>
<p>Admin Panel</p>
</div>
</div>
</div>
{/* Navigation Menu */}
<nav className="sidebar-nav">
<ul className="menu-list">
{menuItems.map((item) => (
<li key={item.id}>
<button
className={`menu-item ${location.pathname === item.path ? "active" : ""}`}
onClick={() => handleNavigation(item.path)}
>
<span className="menu-icon">
<FontAwesomeIcon icon={item.icon} />
</span>
<span className="menu-label">
{item.label}
</span>
{item.badge && (
<span className="menu-badge">
{item.badge}
</span>
)}
<FontAwesomeIcon
icon={faChevronRight}
className="menu-arrow"
/>
</button>
</li>
))}
</ul>
</nav>
{/* User Profile / Logout */}
<div className="sidebar-footer">
<button
className="telegram-button"
onClick={handleTelegram}
disabled={isLoggingOut}
>
<FontAwesomeIcon icon={faTelegram} />
<span>Telegram</span>
</button>
<button
className="logout-button"
onClick={handleLogout}
disabled={isLoggingOut}
>
<FontAwesomeIcon icon={faSignOutAlt} />
<span>
{isLoggingOut ? "Déconnexion..." : "Déconnexion"}
</span>
</button>
</div>
</aside>
</>
);
}
export default Sidebar;
@@ -1,401 +0,0 @@
/* Sidebar Toggle Button */
.sidebar-toggle {
position: fixed;
top: 1rem;
left: 1rem;
z-index: 1001;
width: 48px;
height: 48px;
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 12px;
color: #7c3aed;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.sidebar-toggle.sidebar-open {
left: calc(280px + 1rem);
}
.sidebar-toggle:hover {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.3), rgba(109, 40, 217, 0.15));
border-color: rgba(124, 58, 237, 0.5);
transform: scale(1.05);
}
.sidebar-toggle:active {
transform: scale(0.95);
}
/* Overlay */
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
z-index: 999;
backdrop-filter: blur(4px);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Sidebar Container */
.sidebar {
position: fixed;
left: 0;
top: 0;
height: 100vh;
width: 280px;
background: linear-gradient(180deg, #0f0f0f 0%, #1a1a1a 100%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar::-webkit-scrollbar {
width: 6px;
}
.sidebar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
}
.sidebar::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.3);
border-radius: 3px;
}
.sidebar::-webkit-scrollbar-thumb:hover {
background: rgba(124, 58, 237, 0.5);
}
/* Sidebar Header */
.sidebar-header {
padding: 2rem 1.5rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 1rem;
}
.logo-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #7c3aed, #6d28d9);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
flex-shrink: 0;
}
.logo-text h2 {
color: white;
font-size: 1.3rem;
margin: 0;
font-weight: bold;
letter-spacing: -0.5px;
}
.logo-text p {
color: #888;
font-size: 0.75rem;
margin: 0.2rem 0 0 0;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Navigation */
.sidebar-nav {
flex: 1;
padding: 1.5rem 0;
overflow-y: auto;
}
.menu-list {
list-style: none;
padding: 0;
margin: 0;
}
.menu-list li {
margin-bottom: 0.5rem;
padding: 0 1rem;
}
.menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.9rem 1rem;
background: transparent;
border: 1px solid transparent;
border-radius: 12px;
color: #888;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
text-align: left;
}
.menu-item::before {
content: '';
position: absolute;
left: -100%;
top: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(124, 58, 237, 0.1), transparent);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-item:hover::before {
left: 100%;
}
.menu-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-label {
flex: 1;
}
.menu-badge {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
font-size: 0.75rem;
font-weight: 700;
padding: 0.2rem 0.6rem;
border-radius: 20px;
min-width: 24px;
text-align: center;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
}
.menu-arrow {
flex-shrink: 0;
opacity: 0;
transform: translateX(-8px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-item:hover {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.1), rgba(109, 40, 217, 0.05));
border-color: rgba(124, 58, 237, 0.2);
color: white;
transform: translateX(4px);
}
.menu-item:hover .menu-icon {
transform: scale(1.1);
color: #7c3aed;
}
.menu-item:hover .menu-arrow {
opacity: 1;
transform: translateX(0);
color: #7c3aed;
}
.menu-item.active {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(109, 40, 217, 0.08));
border-color: rgba(124, 58, 237, 0.3);
color: white;
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.menu-item.active .menu-icon {
color: #7c3aed;
}
.menu-item.active .menu-arrow {
opacity: 1;
transform: translateX(0);
color: #7c3aed;
}
.menu-item:active {
transform: scale(0.98) translateX(4px);
}
/* Sidebar Footer */
.sidebar-footer {
padding: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin-top: auto;
}
.user-profile {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
margin-bottom: 1rem;
}
.user-avatar {
width: 44px;
height: 44px;
background: linear-gradient(135deg, #10b981, #059669);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 0.95rem;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
color: white;
font-size: 0.95rem;
font-weight: 600;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
color: #888;
font-size: 0.8rem;
margin: 0.2rem 0 0 0;
}
.logout-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.9rem 1rem;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(220, 38, 38, 0.05));
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 12px;
color: #ef4444;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.logout-button:hover {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(220, 38, 38, 0.08));
border-color: rgba(239, 68, 68, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.logout-button:active {
transform: scale(0.98);
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 280px;
}
.sidebar-toggle.sidebar-open {
left: calc(280px + 1rem);
}
}
@media (max-width: 480px) {
.sidebar {
width: 260px;
}
.sidebar-toggle.sidebar-open {
left: calc(260px + 1rem);
}
.sidebar-header {
padding: 1.5rem 1rem 1rem;
}
.logo-text h2 {
font-size: 1.1rem;
}
.menu-list li {
padding: 0 0.5rem;
}
.menu-item {
padding: 0.8rem 0.8rem;
font-size: 0.9rem;
}
.sidebar-footer {
padding: 1rem;
}
}
@media (hover: none) {
.menu-item:hover {
transform: translateX(0);
}
.menu-item:hover .menu-icon {
transform: none;
}
.sidebar-toggle:hover {
transform: none;
}
.logout-button:hover {
transform: none;
}
}
@@ -1,229 +0,0 @@
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import "./Sidebar.css";
// Importation de Font Awesome
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChartLine,
faShoppingCart,
faUsers,
faSignOutAlt,
faBars,
faTimes,
faChevronRight,
faBoxOpen,
faMotorcycle,
faExclamationCircle,
} from "@fortawesome/free-solid-svg-icons";
import { faTelegram } from "@fortawesome/free-brands-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
interface MenuItem {
id: string;
label: string;
icon: IconDefinition;
path: string;
badge?: number;
}
function SidebarCabine() {
const navigate = useNavigate();
const location = useLocation();
const [isOpen, setIsOpen] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const menuItems: MenuItem[] = [
{
id: "dashboard",
label: "Tableau de Bord",
icon: faChartLine,
path: "/cabine/dashboard",
},
{
id: "orders",
label: "Commandes",
icon: faShoppingCart,
path: "/cabine/dashboard/orders",
},
{
id: "delivery-persons",
label: "Livreurs",
icon: faMotorcycle,
path: "/cabine/dashboard/delivery",
},
{
id: "users",
label: "Utilisateurs",
icon: faUsers,
path: "/cabine/dashboard/users",
},
{
id: "alerts",
label: "Alertes Livreur",
icon: faExclamationCircle,
path: "/cabine/dashboard/alerts",
},
];
const handleNavigation = (path: string) => {
navigate(path);
setIsOpen(false);
};
/**
* ✅ Fonction de déconnexion complète
*/
const handleLogout = async () => {
if (isLoggingOut) return; // Éviter les double-clics
setIsLoggingOut(true);
console.log("🚪 [LOGOUT] Déconnexion en cours...");
try {
const token = sessionStorage.getItem("admin_token");
if (token) {
// ✅ Appel API de logout (via admin API car cabine utilise le même token)
try {
const response = await fetch(
"http://localhost:8080/api/v2/admin/auth/logout",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (response.ok) {
console.log("✅ [LOGOUT] Déconnexion backend réussie");
} else {
console.warn("⚠️ [LOGOUT] Erreur backend (ignorée)");
}
} catch (error) {
console.warn(
"⚠️ [LOGOUT] Erreur réseau backend (ignorée):",
error,
);
}
}
// ✅ CRITIQUE: Nettoyer sessionStorage
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
console.log("✅ [LOGOUT] SessionStorage nettoyé");
navigate("/login-cabine/cabine", { replace: true });
} catch (error) {
console.error("❌ [LOGOUT] Erreur:", error);
// ✅ Même en cas d'erreur, nettoyer et rediriger
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login", { replace: true });
} finally {
setIsLoggingOut(false);
}
};
const handleTelegram = () => {
window.open("https://t.me/milieu_nantais", "_blank");
};
const toggleSidebar = () => {
setIsOpen(!isOpen);
};
return (
<>
{/* Menu Toggle Button */}
<button
className={`sidebar-toggle ${isOpen ? "sidebar-open" : ""}`}
onClick={toggleSidebar}
aria-label="Toggle sidebar"
>
<FontAwesomeIcon icon={isOpen ? faTimes : faBars} />
</button>
{/* Overlay */}
{isOpen && (
<div
className="sidebar-overlay"
onClick={() => setIsOpen(false)}
/>
)}
{/* Sidebar */}
<aside className={`sidebar ${isOpen ? "open" : ""}`}>
{/* Logo / Brand */}
<div className="sidebar-header">
<div className="sidebar-logo">
<div className="logo-icon">
<FontAwesomeIcon icon={faBoxOpen} size="lg" />
</div>
<div className="logo-text">
<h2>Milieu-Nantais</h2>
<p>Cabine Panel</p>
</div>
</div>
</div>
{/* Navigation Menu */}
<nav className="sidebar-nav">
<ul className="menu-list">
{menuItems.map((item) => (
<li key={item.id}>
<button
className={`menu-item ${location.pathname === item.path ? "active" : ""}`}
onClick={() => handleNavigation(item.path)}
disabled={isLoggingOut}
>
<span className="menu-icon">
<FontAwesomeIcon icon={item.icon} />
</span>
<span className="menu-label">
{item.label}
</span>
{item.badge && (
<span className="menu-badge">
{item.badge}
</span>
)}
<FontAwesomeIcon
icon={faChevronRight}
className="menu-arrow"
/>
</button>
</li>
))}
</ul>
</nav>
{/* User Profile / Logout */}
<div className="sidebar-footer">
<button
className="telegram-button"
onClick={handleTelegram}
disabled={isLoggingOut}
>
<FontAwesomeIcon icon={faTelegram} />
<span>Telegram</span>
</button>
<button
className="logout-button"
onClick={handleLogout}
disabled={isLoggingOut}
>
<FontAwesomeIcon icon={faSignOutAlt} />
<span>
{isLoggingOut ? "Déconnexion..." : "Déconnexion"}
</span>
</button>
</div>
</aside>
</>
);
}
export default SidebarCabine;
-513
View File
@@ -1,513 +0,0 @@
.tomtom-map-container {
position: relative;
width: 100%;
height: 400px;
border-radius: 16px;
overflow: hidden;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.tomtom-map-container.navigating {
height: 500px;
}
.tomtom-map {
width: 100%;
height: 100%;
}
/* Marqueur du livreur */
.driver-marker {
cursor: pointer;
}
.driver-marker-inner {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
color: white;
animation: pulse-driver 2s infinite;
}
.driver-marker.navigation-mode .driver-marker-inner {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.5);
}
.driver-arrow {
width: 0;
height: 0;
border-left: 12px solid transparent;
border-right: 12px solid transparent;
border-bottom: 24px solid white;
}
@keyframes pulse-driver {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
}
70% {
box-shadow: 0 0 0 15px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
/* Marqueur de destination */
.destination-marker {
cursor: pointer;
}
.destination-marker-inner {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-radius: 50% 50% 50% 0;
border: 3px solid white;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
color: white;
transform: rotate(-45deg);
}
.destination-marker-inner svg {
transform: rotate(45deg);
}
/* Panneau de navigation */
.navigation-panel {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
background: linear-gradient(
180deg,
rgba(26, 26, 46, 0.98) 0%,
rgba(26, 26, 46, 0.95) 100%
);
backdrop-filter: blur(10px);
border-bottom: 2px solid #10b981;
}
.current-instruction {
display: flex;
align-items: center;
padding: 1rem 1.25rem;
gap: 1rem;
}
.instruction-maneuver {
font-size: 2.5rem;
min-width: 60px;
text-align: center;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
.instruction-details {
flex: 1;
}
.instruction-text {
font-size: 1.1rem;
font-weight: 600;
color: white;
line-height: 1.3;
}
.instruction-street {
font-size: 0.9rem;
color: #10b981;
margin-top: 0.25rem;
font-weight: 500;
}
.instruction-distance {
font-size: 1.5rem;
font-weight: 700;
color: #10b981;
min-width: 80px;
text-align: right;
}
.next-instruction {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
background: rgba(255, 255, 255, 0.05);
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.85rem;
color: #9ca3af;
}
.next-label {
color: #6b7280;
font-weight: 500;
}
.next-maneuver {
font-size: 1.25rem;
}
.next-text {
flex: 1;
}
/* Bouton recentrer */
.recenter-btn {
position: absolute;
bottom: 80px;
right: 16px;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(26, 26, 46, 0.95);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 5;
transition: all 0.2s ease;
}
.recenter-btn:hover {
background: #10b981;
transform: scale(1.1);
}
/* Chargement */
.tomtom-map-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
background: rgba(26, 26, 46, 0.95);
padding: 1.5rem 2rem;
border-radius: 12px;
color: white;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #10b981;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Informations de route */
.tomtom-route-info {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
background: rgba(26, 26, 46, 0.95);
backdrop-filter: blur(10px);
padding: 1rem 1.5rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
z-index: 5;
}
.route-info-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.route-info-label {
font-size: 0.75rem;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.route-info-value {
font-size: 1.25rem;
font-weight: 700;
color: #10b981;
}
.route-info-divider {
width: 1px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
}
/* Bouton afficher instructions */
.show-instructions-btn {
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
border-radius: 8px;
color: white;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.show-instructions-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
/* Liste des instructions */
.instructions-list {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 70px;
background: rgba(26, 26, 46, 0.98);
z-index: 15;
display: flex;
flex-direction: column;
border-radius: 16px 16px 0 0;
}
.instructions-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.instructions-header h4 {
margin: 0;
color: white;
font-size: 1rem;
}
.close-instructions-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.close-instructions-btn:hover {
background: rgba(239, 68, 68, 0.3);
transform: scale(1.1);
}
.instructions-scroll {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0;
}
.instruction-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.2s;
}
.instruction-item.active {
background: rgba(16, 185, 129, 0.15);
border-left: 3px solid #10b981;
}
.instruction-item.passed {
opacity: 0.5;
}
.item-maneuver {
font-size: 1.5rem;
min-width: 40px;
text-align: center;
}
.item-details {
flex: 1;
}
.item-text {
color: white;
font-size: 0.95rem;
display: block;
}
.item-street {
color: #10b981;
font-size: 0.8rem;
display: block;
margin-top: 0.25rem;
}
.item-distance {
color: #9ca3af;
font-size: 0.85rem;
min-width: 60px;
text-align: right;
}
/* Erreur */
.tomtom-map-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 2rem;
text-align: center;
color: white;
}
.tomtom-map-error .error-icon {
color: #f59e0b;
margin-bottom: 1rem;
}
.tomtom-map-error h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
color: #f59e0b;
}
.tomtom-map-error p {
color: #9ca3af;
margin-bottom: 1rem;
}
.tomtom-map-error ol {
text-align: left;
color: #9ca3af;
font-size: 0.9rem;
line-height: 1.8;
}
.tomtom-map-error a {
color: #3b82f6;
text-decoration: underline;
}
.tomtom-map-error code {
background: rgba(59, 130, 246, 0.2);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-family: monospace;
color: #60a5fa;
}
/* Toast d'erreur */
.tomtom-map-error-toast {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: rgba(239, 68, 68, 0.95);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
z-index: 20;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translate(-50%, -20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
/* Responsive */
@media (max-width: 640px) {
.tomtom-map-container {
height: 350px;
border-radius: 12px;
}
.tomtom-map-container.navigating {
height: 450px;
}
.current-instruction {
padding: 0.75rem 1rem;
}
.instruction-maneuver {
font-size: 2rem;
min-width: 50px;
}
.instruction-text {
font-size: 1rem;
}
.instruction-distance {
font-size: 1.25rem;
min-width: 60px;
}
.tomtom-route-info {
flex-wrap: wrap;
padding: 0.75rem 1rem;
gap: 0.5rem;
}
.route-info-value {
font-size: 1rem;
}
.route-info-divider {
height: 30px;
}
.recenter-btn {
bottom: 75px;
width: 40px;
height: 40px;
}
}
-827
View File
@@ -1,827 +0,0 @@
import { useEffect, useRef, useState, useCallback } from "react";
import tt from "@tomtom-international/web-sdk-maps";
import "@tomtom-international/web-sdk-maps/dist/maps.css";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowLeft,
faArrowRight,
faArrowUp,
faArrowTurnUp,
faRotateLeft,
faRotateRight,
faCircleNotch,
faRoad,
faSignOutAlt,
faFlagCheckered,
faCar,
faLocationDot,
faTimes,
faRoute,
faLocationCrosshairs,
} from "@fortawesome/free-solid-svg-icons";
import "./TomTomMap.css";
const TOMTOM_API_KEY = import.meta.env.VITE_TOMTOM_API_KEY;
interface TomTomMapProps {
driverLocation: {
latitude: number;
longitude: number;
} | null;
destinationAddress: string | null;
onRouteCalculated?: (distance: string, duration: string) => void;
onError?: (error: string) => void;
isNavigating?: boolean;
}
interface RouteInfo {
distance: string;
duration: string;
distanceRemaining: number;
timeRemaining: number;
}
interface NavigationInstruction {
instruction: string;
distance: string;
maneuverIcon: any;
streetName?: string;
isActive: boolean;
}
// Mapping des maneuvres vers des icones Font Awesome
const maneuverIcons: Record<string, any> = {
TURN_LEFT: faArrowLeft,
TURN_RIGHT: faArrowRight,
TURN_SLIGHT_LEFT: faArrowTurnUp,
TURN_SLIGHT_RIGHT: faArrowTurnUp,
TURN_SHARP_LEFT: faRotateLeft,
TURN_SHARP_RIGHT: faRotateRight,
KEEP_LEFT: faArrowLeft,
KEEP_RIGHT: faArrowRight,
STRAIGHT: faArrowUp,
ENTER_ROUNDABOUT: faCircleNotch,
EXIT_ROUNDABOUT: faArrowRight,
MOTORWAY_ENTER: faRoad,
MOTORWAY_EXIT: faSignOutAlt,
ARRIVE: faFlagCheckered,
ARRIVE_LEFT: faFlagCheckered,
ARRIVE_RIGHT: faFlagCheckered,
DEPART: faCar,
U_TURN: faRotateLeft,
FOLLOW: faArrowUp,
WAYPOINT_REACHED: faLocationDot,
DEFAULT: faArrowUp,
};
// Mapping des maneuvres vers des textes en francais
const maneuverTranslations: Record<string, string> = {
TURN_LEFT: "Tournez a gauche",
TURN_RIGHT: "Tournez a droite",
TURN_SLIGHT_LEFT: "Tournez legerement a gauche",
TURN_SLIGHT_RIGHT: "Tournez legerement a droite",
TURN_SHARP_LEFT: "Tournez fortement a gauche",
TURN_SHARP_RIGHT: "Tournez fortement a droite",
KEEP_LEFT: "Restez a gauche",
KEEP_RIGHT: "Restez a droite",
STRAIGHT: "Continuez tout droit",
ENTER_ROUNDABOUT: "Entrez dans le rond-point",
EXIT_ROUNDABOUT: "Sortez du rond-point",
MOTORWAY_ENTER: "Entrez sur l'autoroute",
MOTORWAY_EXIT: "Sortez de l'autoroute",
ARRIVE: "Vous etes arrive",
ARRIVE_LEFT: "Destination a gauche",
ARRIVE_RIGHT: "Destination a droite",
DEPART: "Depart",
U_TURN: "Faites demi-tour",
FOLLOW: "Suivez",
WAYPOINT_REACHED: "Point de passage atteint",
};
function TomTomMap({
driverLocation,
destinationAddress,
onRouteCalculated,
onError,
isNavigating = false,
}: TomTomMapProps) {
const mapContainer = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapInstance = useRef<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const driverMarker = useRef<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const destinationMarker = useRef<any>(null);
const routeLayerId = useRef<string>("route-layer");
const routeCoordinates = useRef<[number, number][]>([]);
const [isMapReady, setIsMapReady] = useState(false);
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
const [destinationCoords, setDestinationCoords] = useState<{
lat: number;
lng: number;
} | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [instructions, setInstructions] = useState<NavigationInstruction[]>(
[],
);
const [currentInstructionIndex, setCurrentInstructionIndex] = useState(0);
const [showAllInstructions, setShowAllInstructions] = useState(false);
// Calculer la distance entre deux points (formule Haversine)
const calculateDistance = useCallback(
(lat1: number, lon1: number, lat2: number, lon2: number): number => {
const R = 6371e3; // Rayon de la Terre en metres
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) *
Math.cos(φ2) *
Math.sin(Δλ / 2) *
Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
},
[],
);
// Formater la distance
const formatDistance = useCallback((meters: number): string => {
if (meters < 1000) {
return `${Math.round(meters)} m`;
}
return `${(meters / 1000).toFixed(1)} km`;
}, []);
// Initialiser la carte
useEffect(() => {
if (
!mapContainer.current ||
!TOMTOM_API_KEY ||
TOMTOM_API_KEY === "YOUR_TOMTOM_API_KEY_HERE"
) {
setError(
"Cle API TomTom non configuree. Veuillez ajouter VITE_TOMTOM_API_KEY dans .env",
);
onError?.("Cle API TomTom non configuree");
return;
}
const map = tt.map({
key: TOMTOM_API_KEY,
container: mapContainer.current,
center: driverLocation
? [driverLocation.longitude, driverLocation.latitude]
: [2.3522, 48.8566],
zoom: 15,
language: "fr-FR",
});
map.addControl(new tt.NavigationControl());
map.on("load", () => {
console.log("TomTom Map chargee");
setIsMapReady(true);
});
mapInstance.current = map;
return () => {
if (mapInstance.current) {
mapInstance.current.remove();
mapInstance.current = null;
}
};
}, []);
// Creer le marqueur du livreur (style navigation)
const createDriverMarkerElement = useCallback(() => {
const el = document.createElement("div");
el.className = "driver-marker navigation-mode";
el.innerHTML = `
<div class="driver-marker-inner">
<div class="driver-arrow"></div>
</div>
`;
return el;
}, []);
// Creer le marqueur de destination
const createDestinationMarkerElement = useCallback(() => {
const el = document.createElement("div");
el.className = "destination-marker";
el.innerHTML = `
<div class="destination-marker-inner">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
</div>
`;
return el;
}, []);
// Mettre a jour la position du livreur et centrer la carte en mode navigation
useEffect(() => {
if (!isMapReady || !mapInstance.current || !driverLocation) return;
const { latitude, longitude } = driverLocation;
if (driverMarker.current) {
driverMarker.current.setLngLat([longitude, latitude]);
} else {
driverMarker.current = new tt.Marker({
element: createDriverMarkerElement(),
})
.setLngLat([longitude, latitude])
.addTo(mapInstance.current);
}
// En mode navigation, suivre le livreur
if (isNavigating && destinationCoords) {
mapInstance.current.easeTo({
center: [longitude, latitude],
zoom: 17,
pitch: 60,
bearing: calculateBearing(
latitude,
longitude,
destinationCoords.lat,
destinationCoords.lng,
),
});
// Mettre a jour l'instruction active en fonction de la position
updateCurrentInstruction(latitude, longitude);
} else if (!destinationCoords) {
mapInstance.current.flyTo({
center: [longitude, latitude],
zoom: 15,
});
}
}, [
isMapReady,
driverLocation,
isNavigating,
destinationCoords,
createDriverMarkerElement,
]);
// Calculer l'angle de direction (bearing)
const calculateBearing = (
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number => {
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x =
Math.cos(φ1) * Math.sin(φ2) -
Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
const θ = Math.atan2(y, x);
return ((θ * 180) / Math.PI + 360) % 360;
};
// Mettre a jour l'instruction courante basee sur la position
const updateCurrentInstruction = useCallback(
(lat: number, lng: number) => {
if (instructions.length === 0) return;
// Trouver l'instruction la plus proche
let minDistance = Infinity;
let closestIndex = currentInstructionIndex;
// Chercher parmi les instructions restantes
for (
let i = currentInstructionIndex;
i < instructions.length;
i++
) {
// Utiliser les coordonnees de la route pour trouver le point le plus proche
if (routeCoordinates.current.length > 0) {
const segmentStart = Math.floor(
(i / instructions.length) *
routeCoordinates.current.length,
);
if (segmentStart < routeCoordinates.current.length) {
const [pointLng, pointLat] =
routeCoordinates.current[segmentStart];
const dist = calculateDistance(
lat,
lng,
pointLat,
pointLng,
);
if (dist < minDistance) {
minDistance = dist;
closestIndex = i;
}
}
}
}
// Si on est a moins de 50m d'une instruction, passer a la suivante
if (minDistance < 50 && closestIndex > currentInstructionIndex) {
setCurrentInstructionIndex(closestIndex);
// Mettre a jour les instructions pour marquer l'active
setInstructions((prev) =>
prev.map((inst, idx) => ({
...inst,
isActive: idx === closestIndex,
})),
);
}
},
[instructions, currentInstructionIndex, calculateDistance],
);
// Geocoder l'adresse de destination
useEffect(() => {
if (
!destinationAddress ||
!TOMTOM_API_KEY ||
TOMTOM_API_KEY === "YOUR_TOMTOM_API_KEY_HERE"
)
return;
setIsLoading(true);
setError(null);
// Utiliser l'API REST directement pour le geocodage
const searchUrl = `https://api.tomtom.com/search/2/geocode/${encodeURIComponent(destinationAddress)}.json?key=${TOMTOM_API_KEY}&countrySet=FR&limit=1`;
fetch(searchUrl)
.then((response) => response.json())
.then((data) => {
if (data.results && data.results.length > 0) {
const result = data.results[0];
if (
result.position?.lat !== undefined &&
result.position?.lon !== undefined
) {
const coords = {
lat: result.position.lat,
lng: result.position.lon,
};
console.log("Destination geocodee:", coords);
setDestinationCoords(coords);
} else {
setError("Position de destination invalide");
onError?.("Position de destination invalide");
}
} else {
setError("Adresse de destination introuvable");
onError?.("Adresse de destination introuvable");
}
})
.catch((err) => {
console.error("Erreur geocodage:", err);
setError("Erreur lors du geocodage de l'adresse");
onError?.("Erreur lors du geocodage de l'adresse");
})
.finally(() => {
setIsLoading(false);
});
}, [destinationAddress, onError]);
// Ajouter le marqueur de destination et calculer l'itineraire
useEffect(() => {
if (!isMapReady || !mapInstance.current || !destinationCoords) return;
if (destinationMarker.current) {
destinationMarker.current.setLngLat([
destinationCoords.lng,
destinationCoords.lat,
]);
} else {
destinationMarker.current = new tt.Marker({
element: createDestinationMarkerElement(),
})
.setLngLat([destinationCoords.lng, destinationCoords.lat])
.addTo(mapInstance.current);
}
if (driverLocation) {
calculateRoute();
} else {
mapInstance.current.flyTo({
center: [destinationCoords.lng, destinationCoords.lat],
zoom: 15,
});
}
}, [
isMapReady,
destinationCoords,
driverLocation,
createDestinationMarkerElement,
]);
// Calculer l'itineraire avec instructions via API REST directe
const calculateRoute = useCallback(async () => {
if (
!mapInstance.current ||
!driverLocation ||
!destinationCoords ||
!TOMTOM_API_KEY ||
TOMTOM_API_KEY === "YOUR_TOMTOM_API_KEY_HERE"
)
return;
setIsLoading(true);
try {
// Utiliser l'API REST directement pour avoir les points de la route
const startPoint = `${driverLocation.latitude},${driverLocation.longitude}`;
const endPoint = `${destinationCoords.lat},${destinationCoords.lng}`;
const routeUrl = `https://api.tomtom.com/routing/1/calculateRoute/${startPoint}:${endPoint}/json?key=${TOMTOM_API_KEY}&instructionsType=text&language=fr-FR&traffic=true&travelMode=car`;
console.log("Appel API TomTom Routing...");
const response = await fetch(routeUrl);
const routeData = await response.json();
console.log("Route response:", routeData);
if (!routeData.routes || routeData.routes.length === 0) {
setError("Aucun itineraire trouve");
onError?.("Aucun itineraire trouve");
setIsLoading(false);
return;
}
const route = routeData.routes[0];
let coordinates: [number, number][] = [];
// Extraire les coordonnees des legs
if (route.legs && route.legs.length > 0) {
route.legs.forEach((leg: any) => {
if (leg.points && leg.points.length > 0) {
leg.points.forEach((point: any) => {
coordinates.push([point.longitude, point.latitude]);
});
}
});
}
console.log("Coordonnees extraites:", coordinates.length, "points");
// Fallback: ligne droite si pas de points
if (coordinates.length === 0) {
console.log("Fallback: utilisation ligne droite");
coordinates = [
[driverLocation.longitude, driverLocation.latitude],
[destinationCoords.lng, destinationCoords.lat],
];
}
routeCoordinates.current = coordinates;
// Extraire les instructions de navigation
const navInstructions: NavigationInstruction[] = [];
if (
route.guidance?.instructions &&
route.guidance.instructions.length > 0
) {
route.guidance.instructions.forEach(
(inst: any, index: number) => {
const maneuverKey = inst.maneuver || "STRAIGHT";
const icon =
maneuverIcons[maneuverKey] || maneuverIcons.DEFAULT;
const text =
maneuverTranslations[maneuverKey] || "Continuez";
navInstructions.push({
instruction: inst.message || text,
distance: formatDistance(
inst.routeOffsetInMeters || 0,
),
maneuverIcon: icon,
streetName: inst.street,
isActive: index === 0,
});
},
);
}
// Si pas d'instructions, creer des instructions basiques
if (navInstructions.length === 0) {
navInstructions.push({
instruction: "Dirigez-vous vers votre destination",
distance: formatDistance(
route.summary?.lengthInMeters || 0,
),
maneuverIcon: faCar,
isActive: true,
});
navInstructions.push({
instruction: "Vous etes arrive a destination",
distance: "0 m",
maneuverIcon: faFlagCheckered,
isActive: false,
});
}
setInstructions(navInstructions);
setCurrentInstructionIndex(0);
// Construire le GeoJSON pour la ligne
const geojson = {
type: "Feature" as const,
properties: {},
geometry: {
type: "LineString" as const,
coordinates: coordinates,
},
};
console.log("GeoJSON cree avec", coordinates.length, "points");
// Supprimer l'ancien itineraire s'il existe
try {
if (mapInstance.current?.getLayer(routeLayerId.current)) {
mapInstance.current.removeLayer(routeLayerId.current);
}
if (mapInstance.current?.getSource(routeLayerId.current)) {
mapInstance.current.removeSource(routeLayerId.current);
}
} catch (e) {
console.log("Pas d'ancien itineraire a supprimer");
}
// Ajouter le nouvel itineraire
mapInstance.current?.addSource(routeLayerId.current, {
type: "geojson",
data: geojson,
});
mapInstance.current?.addLayer({
id: routeLayerId.current,
type: "line",
source: routeLayerId.current,
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": "#4285F4",
"line-width": 6,
"line-opacity": 0.8,
},
});
console.log("Itineraire ajoute a la carte");
// Informations de l'itineraire
const summary = route.summary;
const distanceKm = (summary.lengthInMeters / 1000).toFixed(1);
const durationMin = Math.round(summary.travelTimeInSeconds / 60);
const info: RouteInfo = {
distance: `${distanceKm} km`,
duration: `${durationMin} min`,
distanceRemaining: summary.lengthInMeters,
timeRemaining: summary.travelTimeInSeconds,
};
setRouteInfo(info);
onRouteCalculated?.(info.distance, info.duration);
// Ajuster la vue pour montrer tout l'itineraire
const bounds = new tt.LngLatBounds();
bounds.extend([driverLocation.longitude, driverLocation.latitude]);
bounds.extend([destinationCoords.lng, destinationCoords.lat]);
mapInstance.current?.fitBounds(bounds, {
padding: 80,
});
console.log(
"Itineraire calcule avec",
navInstructions.length,
"instructions",
);
} catch (err) {
console.error("Erreur calcul itineraire:", err);
setError("Erreur lors du calcul de l'itineraire");
onError?.("Erreur lors du calcul de l'itineraire");
} finally {
setIsLoading(false);
}
}, [
driverLocation,
destinationCoords,
onRouteCalculated,
onError,
formatDistance,
]);
// Recentrer sur le livreur
const centerOnDriver = () => {
if (mapInstance.current && driverLocation) {
mapInstance.current.flyTo({
center: [driverLocation.longitude, driverLocation.latitude],
zoom: 17,
});
}
};
if (
error &&
(!TOMTOM_API_KEY || TOMTOM_API_KEY === "YOUR_TOMTOM_API_KEY_HERE")
) {
return (
<div className="tomtom-map-container">
<div className="tomtom-map-error">
<div className="error-icon">
<svg
viewBox="0 0 24 24"
fill="currentColor"
width="48"
height="48"
>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
</div>
<h3>Configuration requise</h3>
<p>{error}</p>
<ol>
<li>
Allez sur{" "}
<a
href="https://developer.tomtom.com/"
target="_blank"
rel="noopener noreferrer"
>
developer.tomtom.com
</a>
</li>
<li>Creez un compte gratuit</li>
<li>Obtenez une cle API</li>
<li>
Ajoutez-la dans le fichier <code>.env</code>
</li>
</ol>
</div>
</div>
);
}
const currentInstruction = instructions[currentInstructionIndex];
const nextInstruction = instructions[currentInstructionIndex + 1];
return (
<div
className={`tomtom-map-container ${isNavigating ? "navigating" : ""}`}
>
{isLoading && (
<div className="tomtom-map-loading">
<div className="loading-spinner"></div>
<span>Calcul de l'itineraire...</span>
</div>
)}
{/* Panneau d'instruction principale */}
{isNavigating && currentInstruction && (
<div className="navigation-panel">
<div className="current-instruction">
<div className="instruction-maneuver">
<FontAwesomeIcon
icon={currentInstruction.maneuverIcon}
/>
</div>
<div className="instruction-details">
<div className="instruction-text">
{currentInstruction.instruction}
</div>
{currentInstruction.streetName && (
<div className="instruction-street">
{currentInstruction.streetName}
</div>
)}
</div>
<div className="instruction-distance">
{currentInstruction.distance}
</div>
</div>
{nextInstruction && (
<div className="next-instruction">
<span className="next-label">Puis</span>
<span className="next-maneuver">
<FontAwesomeIcon
icon={nextInstruction.maneuverIcon}
/>
</span>
<span className="next-text">
{nextInstruction.instruction}
</span>
</div>
)}
</div>
)}
<div ref={mapContainer} className="tomtom-map" />
{/* Bouton recentrer */}
{driverLocation && (
<button
className="recenter-btn"
onClick={centerOnDriver}
title="Recentrer"
>
<FontAwesomeIcon icon={faLocationCrosshairs} />
</button>
)}
{/* Infos de route */}
{routeInfo && (
<div className="tomtom-route-info">
<div className="route-info-item">
<span className="route-info-label">Distance</span>
<span className="route-info-value">
{routeInfo.distance}
</span>
</div>
<div className="route-info-divider" />
<div className="route-info-item">
<span className="route-info-label">Duree</span>
<span className="route-info-value">
{routeInfo.duration}
</span>
</div>
{instructions.length > 0 && (
<>
<div className="route-info-divider" />
<button
className="show-instructions-btn"
onClick={() =>
setShowAllInstructions(!showAllInstructions)
}
>
<FontAwesomeIcon
icon={faRoute}
style={{ marginRight: "0.5rem" }}
/>
{showAllInstructions ? "Masquer" : "Etapes"} (
{instructions.length})
</button>
</>
)}
</div>
)}
{/* Liste des instructions */}
{showAllInstructions && instructions.length > 0 && (
<div className="instructions-list">
<div className="instructions-header">
<h4>Etapes de l'itineraire</h4>
<button
className="close-instructions-btn"
onClick={() => setShowAllInstructions(false)}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
<div className="instructions-scroll">
{instructions.map((inst, index) => (
<div
key={index}
className={`instruction-item ${index === currentInstructionIndex ? "active" : ""} ${index < currentInstructionIndex ? "passed" : ""}`}
>
<span className="item-maneuver">
<FontAwesomeIcon icon={inst.maneuverIcon} />
</span>
<div className="item-details">
<span className="item-text">
{inst.instruction}
</span>
{inst.streetName && (
<span className="item-street">
{inst.streetName}
</span>
)}
</div>
<span className="item-distance">
{inst.distance}
</span>
</div>
))}
</div>
</div>
)}
{error && <div className="tomtom-map-error-toast">{error}</div>}
</div>
);
}
export default TomTomMap;