Added Calendar Page

This commit is contained in:
2026-01-21 17:34:48 +01:00
parent 3a8a6d1576
commit c9f6b53c13
14 changed files with 2188 additions and 9 deletions

429
components/TrajetMap.tsx Normal file
View File

@@ -0,0 +1,429 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import dynamic from 'next/dynamic';
import L from 'leaflet';
// Fix pour les icônes Leaflet avec Next.js
if (typeof window !== 'undefined') {
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});
}
// Import dynamique pour éviter les problèmes SSR avec Leaflet
const MapContainer = dynamic(() => import('react-leaflet').then((mod) => mod.MapContainer), { ssr: false });
const TileLayer = dynamic(() => import('react-leaflet').then((mod) => mod.TileLayer), { ssr: false });
const Marker = dynamic(() => import('react-leaflet').then((mod) => mod.Marker), { ssr: false });
const Popup = dynamic(() => import('react-leaflet').then((mod) => mod.Popup), { ssr: false });
const Polyline = dynamic(() => import('react-leaflet').then((mod) => mod.Polyline), { ssr: false });
interface TrajetMapProps {
adresseDepart: string;
adresseArrivee: string;
adherentNom?: string;
}
interface Coordinates {
lat: number;
lng: number;
}
interface RouteInfo {
distance: number; // en mètres
duration: number; // en secondes
}
export default function TrajetMap({ adresseDepart, adresseArrivee, adherentNom }: TrajetMapProps) {
const [departCoords, setDepartCoords] = useState<Coordinates | null>(null);
const [arriveeCoords, setArriveeCoords] = useState<Coordinates | null>(null);
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Cache simple pour éviter de regéocoder les mêmes adresses
const geocodeCacheRef = useRef<Map<string, Coordinates>>(new Map());
useEffect(() => {
// Réinitialiser les coordonnées quand les adresses changent
setDepartCoords(null);
setArriveeCoords(null);
setRouteInfo(null);
setError(null);
if (adresseDepart && adresseArrivee && adresseDepart.length >= 5 && adresseArrivee.length >= 5) {
// Délai réduit pour une réponse plus rapide
const timeoutId = setTimeout(() => {
geocodeAddresses();
}, 400);
return () => clearTimeout(timeoutId);
}
}, [adresseDepart, adresseArrivee]);
const geocodeAddresses = async () => {
if (!adresseDepart || !adresseArrivee) {
return;
}
// Vérifier que les adresses ont au moins 5 caractères
if (adresseDepart.length < 5 || adresseArrivee.length < 5) {
return;
}
setLoading(true);
setError(null);
try {
// Géocoder les deux adresses en parallèle pour plus de rapidité
// (l'API Nominatim permet généralement 1 req/sec, mais on peut essayer)
const [departResult, arriveeResult] = await Promise.all([
geocodeAddress(adresseDepart).catch(() => null),
// Petit délai pour la deuxième requête pour respecter les limites
new Promise(resolve => setTimeout(resolve, 1000)).then(() =>
geocodeAddress(adresseArrivee).catch(() => null)
),
]);
if (departResult && arriveeResult) {
setDepartCoords(departResult);
setArriveeCoords(arriveeResult);
// Calculer la distance et le temps estimé
const info = calculateRouteInfo(departResult, arriveeResult);
setRouteInfo(info);
} else if (!departResult) {
setError(`Impossible de trouver l'adresse de départ: "${adresseDepart}"`);
} else {
setError(`Impossible de trouver l'adresse d'arrivée: "${adresseArrivee}"`);
}
} catch (err) {
console.error('Erreur lors du géocodage:', err);
setError('Erreur lors du chargement de la carte. Veuillez réessayer.');
} finally {
setLoading(false);
}
};
const geocodeAddress = async (address: string): Promise<Coordinates | null> => {
if (!address || address.length < 5) {
return null;
}
try {
// Nettoyer l'adresse pour améliorer les résultats
const cleanAddress = address.trim().toLowerCase();
// Vérifier le cache
if (geocodeCacheRef.current.has(cleanAddress)) {
return geocodeCacheRef.current.get(cleanAddress)!;
}
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address.trim())}&limit=1&addressdetails=1&countrycodes=fr`,
{
headers: {
'User-Agent': 'MAD Platform',
'Accept-Language': 'fr-FR,fr;q=0.9',
'Referer': window.location.origin,
},
}
);
if (response.ok) {
const data = await response.json();
if (data && data.length > 0 && data[0].lat && data[0].lon) {
const lat = parseFloat(data[0].lat);
const lng = parseFloat(data[0].lon);
// Vérifier que les coordonnées sont valides
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
const coords = { lat, lng };
// Mettre en cache (limiter à 50 entrées pour éviter la surconsommation mémoire)
if (geocodeCacheRef.current.size < 50) {
geocodeCacheRef.current.set(cleanAddress, coords);
}
return coords;
}
}
} else {
console.warn('Réponse non OK de Nominatim:', response.status);
}
return null;
} catch (error) {
console.error('Erreur de géocodage:', error);
return null;
}
};
const calculateRouteInfo = (depart: Coordinates, arrivee: Coordinates): RouteInfo => {
// Calcul de la distance à vol d'oiseau (formule de Haversine)
const R = 6371e3; // Rayon de la Terre en mètres
const φ1 = (depart.lat * Math.PI) / 180;
const φ2 = (arrivee.lat * Math.PI) / 180;
const Δφ = ((arrivee.lat - depart.lat) * Math.PI) / 180;
const Δλ = ((arrivee.lng - depart.lng) * 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));
const distance = R * c; // Distance en mètres
// Estimation du temps : vitesse moyenne de 50 km/h en ville
// On multiplie la distance par 1.3 pour tenir compte des détours
const distanceWithDetour = distance * 1.3;
const vitesseMoyenne = 50; // km/h
const duration = (distanceWithDetour / 1000 / vitesseMoyenne) * 3600; // en secondes
return { distance: distanceWithDetour, duration };
};
const formatDistance = (meters: number): string => {
if (meters < 1000) {
return `${Math.round(meters)} m`;
}
return `${(meters / 1000).toFixed(1)} km`;
};
const formatDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h${minutes > 0 ? minutes : ''}`;
}
return `${minutes} min`;
};
if (!adresseDepart || !adresseArrivee) {
return (
<div className="h-full flex items-center justify-center text-gray-400">
<div className="text-center">
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<p className="text-sm">Remplissez les adresses pour voir la carte</p>
</div>
</div>
);
}
if (loading) {
return (
<div className="h-full flex items-center justify-center bg-white rounded-xl">
<div className="text-center">
<svg className="animate-spin h-12 w-12 text-lblue mx-auto mb-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-sm font-medium text-gray-700 mb-1">Chargement de la carte...</p>
<p className="text-xs text-gray-500">Géocodage des adresses en cours</p>
</div>
</div>
);
}
if (error) {
return (
<div className="h-full flex items-center justify-center text-gray-400 bg-white rounded-xl p-8">
<div className="text-center max-w-md">
<svg className="w-20 h-20 mx-auto mb-4 opacity-50 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-sm font-medium text-gray-700 mb-2">{error}</p>
<p className="text-xs text-gray-500 mb-4">Vérifiez que les adresses sont correctes et complètes</p>
<button
onClick={geocodeAddresses}
className="px-4 py-2 text-xs font-medium text-white bg-lblue rounded-lg hover:bg-dblue transition-colors"
>
Réessayer
</button>
</div>
</div>
);
}
if (!departCoords || !arriveeCoords) {
return (
<div className="h-full flex items-center justify-center text-gray-400">
<div className="text-center">
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<p className="text-sm">Chargement de la carte...</p>
</div>
</div>
);
}
// Calculer le centre et le zoom optimal pour afficher les deux points
const centerLat = departCoords && arriveeCoords ? (departCoords.lat + arriveeCoords.lat) / 2 : 48.8566;
const centerLng = departCoords && arriveeCoords ? (departCoords.lng + arriveeCoords.lng) / 2 : 2.3522;
// Calculer le zoom optimal pour voir les deux points
let optimalZoom = 12;
if (departCoords && arriveeCoords) {
const latDiff = Math.abs(departCoords.lat - arriveeCoords.lat);
const lngDiff = Math.abs(departCoords.lng - arriveeCoords.lng);
const maxDiff = Math.max(latDiff, lngDiff);
if (maxDiff > 0.5) optimalZoom = 7;
else if (maxDiff > 0.2) optimalZoom = 8;
else if (maxDiff > 0.1) optimalZoom = 9;
else if (maxDiff > 0.05) optimalZoom = 10;
else if (maxDiff > 0.02) optimalZoom = 11;
else optimalZoom = 12;
}
if (typeof window === 'undefined') {
return null;
}
return (
<div className="h-full flex flex-col min-h-[500px]">
{/* Informations du trajet */}
{routeInfo && (
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4 shadow-sm">
<div className="grid grid-cols-2 gap-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-lgreen/10 flex items-center justify-center">
<svg className="w-5 h-5 text-lgreen" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Distance</div>
<div className="text-xl font-semibold text-gray-900">{formatDistance(routeInfo.distance)}</div>
</div>
</div>
<div className="flex items-center gap-3 border-l border-gray-200 pl-6">
<div className="w-10 h-10 rounded-lg bg-lblue/10 flex items-center justify-center">
<svg className="w-5 h-5 text-lblue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Temps estimé</div>
<div className="text-xl font-semibold text-gray-900">{formatDuration(routeInfo.duration)}</div>
</div>
</div>
</div>
</div>
)}
{/* Carte */}
<div className="flex-1 rounded-xl overflow-hidden border-2 border-gray-300 shadow-2xl" style={{ minHeight: '500px' }}>
<MapContainer
center={[centerLat, centerLng]}
zoom={optimalZoom}
style={{ height: '100%', width: '100%', zIndex: 0 }}
zoomControl={true}
scrollWheelZoom={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker
position={[departCoords.lat, departCoords.lng]}
icon={L.divIcon({
className: 'custom-marker-depart',
html: `
<div style="
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
width: 40px;
height: 40px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
border: 4px solid white;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
">
<div style="
transform: rotate(45deg);
color: white;
font-weight: bold;
font-size: 18px;
">A</div>
</div>
`,
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40],
})}
>
<Popup className="custom-popup">
<div className="text-sm p-2">
<div className="font-bold text-lgreen mb-1 flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-lgreen"></div>
Départ
</div>
<div className="text-gray-700 font-medium">{adresseDepart}</div>
{adherentNom && <div className="text-gray-500 mt-1 text-xs">{adherentNom}</div>}
</div>
</Popup>
</Marker>
<Marker
position={[arriveeCoords.lat, arriveeCoords.lng]}
icon={L.divIcon({
className: 'custom-marker-arrivee',
html: `
<div style="
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
width: 40px;
height: 40px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
border: 4px solid white;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
">
<div style="
transform: rotate(45deg);
color: white;
font-weight: bold;
font-size: 18px;
">B</div>
</div>
`,
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40],
})}
>
<Popup className="custom-popup">
<div className="text-sm p-2">
<div className="font-bold text-lblue mb-1 flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-lblue"></div>
Arrivée
</div>
<div className="text-gray-700 font-medium">{adresseArrivee}</div>
</div>
</Popup>
</Marker>
<Polyline
positions={[
[departCoords.lat, departCoords.lng],
[arriveeCoords.lat, arriveeCoords.lng],
]}
pathOptions={{
color: '#6B46C1',
weight: 5,
opacity: 0.8,
dashArray: '10, 10',
}}
/>
</MapContainer>
</div>
</div>
);
}