From 5c37790ab78b78be9662cc937455b785a3ade862 Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 09:36:16 +0100 Subject: [PATCH 01/13] feat: add font Arial for everything --- client/app/globals.scss | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/app/globals.scss b/client/app/globals.scss index 4c9bf11..be07a3f 100644 --- a/client/app/globals.scss +++ b/client/app/globals.scss @@ -15,9 +15,7 @@ body { } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-family: "Arial", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } From 8479d7dfac6640c4db7b96e4b672ecece60f48d7 Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 09:54:16 +0100 Subject: [PATCH 02/13] refactor: clean architecture --- client/app/hopitaux/[id]/page.tsx | 6 ++---- client/app/hopitaux/page.tsx | 2 +- client/app/page.tsx | 3 +-- client/components/SearchBar.tsx | 1 - client/{app/components => components/home}/FAQSection.tsx | 0 .../components => components/hopitaux}/HospitalCard.tsx | 0 .../components => components/hopitaux}/HospitalList.tsx | 2 +- 7 files changed, 5 insertions(+), 9 deletions(-) rename client/{app/components => components/home}/FAQSection.tsx (100%) rename client/{app/hopitaux/components => components/hopitaux}/HospitalCard.tsx (100%) rename client/{app/hopitaux/components => components/hopitaux}/HospitalList.tsx (90%) diff --git a/client/app/hopitaux/[id]/page.tsx b/client/app/hopitaux/[id]/page.tsx index 58695ac..62eef2d 100644 --- a/client/app/hopitaux/[id]/page.tsx +++ b/client/app/hopitaux/[id]/page.tsx @@ -333,10 +333,8 @@ export default function HospitalDetailPage({ params }: { params: Promise<{ id: s })()} {!mockData && !accessibilityOptions && ( -
-

- Les spécifications de cet établissement ne sont pas encore disponibles. -

+
+
)}
diff --git a/client/app/hopitaux/page.tsx b/client/app/hopitaux/page.tsx index 0710cc4..781e2e0 100644 --- a/client/app/hopitaux/page.tsx +++ b/client/app/hopitaux/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from 'react'; import Header from '@/components/Header'; -import HospitalList from './components/HospitalList'; +import HospitalList from '@/components/hopitaux/HospitalList'; import SearchBar from '@/components/SearchBar'; import MultiSelectFilter from '@/components/MultiSelectFilter'; import Loading from '@/components/Loading'; diff --git a/client/app/page.tsx b/client/app/page.tsx index 77dce60..4a57977 100644 --- a/client/app/page.tsx +++ b/client/app/page.tsx @@ -1,8 +1,7 @@ import Image from 'next/image' import Link from 'next/link'; import Header from '@/components/Header' -import MapWrapper from '@/components/MapWrapper' -import FAQSection from '@/app/components/FAQSection' +import FAQSection from '@/components/home/FAQSection' export default function Home() { return ( diff --git a/client/components/SearchBar.tsx b/client/components/SearchBar.tsx index f4d54fe..128e1e1 100644 --- a/client/components/SearchBar.tsx +++ b/client/components/SearchBar.tsx @@ -40,7 +40,6 @@ export default function SearchBar({ "rounded-full", "px-6 py-3", "shadow-sm", - "font-[Arial]", "focus-within:ring-4 focus-within:ring-red-600", className, ].join(" ")} diff --git a/client/app/components/FAQSection.tsx b/client/components/home/FAQSection.tsx similarity index 100% rename from client/app/components/FAQSection.tsx rename to client/components/home/FAQSection.tsx diff --git a/client/app/hopitaux/components/HospitalCard.tsx b/client/components/hopitaux/HospitalCard.tsx similarity index 100% rename from client/app/hopitaux/components/HospitalCard.tsx rename to client/components/hopitaux/HospitalCard.tsx diff --git a/client/app/hopitaux/components/HospitalList.tsx b/client/components/hopitaux/HospitalList.tsx similarity index 90% rename from client/app/hopitaux/components/HospitalList.tsx rename to client/components/hopitaux/HospitalList.tsx index f7f080a..0abcfdc 100644 --- a/client/app/hopitaux/components/HospitalList.tsx +++ b/client/components/hopitaux/HospitalList.tsx @@ -1,4 +1,4 @@ -import HospitalCard from "./HospitalCard"; +import HospitalCard from "@/components/hopitaux/HospitalCard"; import { HospitalWithMock } from "@/types/api"; import NotFoundData from "@/components/NotFoundData"; From 87e12d66eb8d5907095fecc9119d8d9ba688dd06 Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 10:42:27 +0100 Subject: [PATCH 03/13] feat: add metadata for SEO --- client/app/hopitaux/[id]/layout.tsx | 46 +++++++++++++++++++++++++++++ client/app/hopitaux/layout.tsx | 23 +++++++++++++++ client/app/layout.tsx | 7 +++-- client/app/map/layout.tsx | 23 +++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 client/app/hopitaux/[id]/layout.tsx create mode 100644 client/app/hopitaux/layout.tsx create mode 100644 client/app/map/layout.tsx diff --git a/client/app/hopitaux/[id]/layout.tsx b/client/app/hopitaux/[id]/layout.tsx new file mode 100644 index 0000000..4038dce --- /dev/null +++ b/client/app/hopitaux/[id]/layout.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from 'next'; + +type Props = { + params: Promise<{ id: string }>; +}; + +async function getHospitalName(id: string): Promise { + const baseUrl = process.env.NEXT_PUBLIC_HOSPITALS_SINGLE_API_URL; + + if (!baseUrl) { + console.error('NEXT_PUBLIC_HOSPITALS_SINGLE_API_URL manquant'); + return null; + } + + const apiUrl = `${baseUrl}&rows=1&q=recordid:${id}`; + const res = await fetch(apiUrl, { next: { revalidate: 3600 } }); + + if (!res.ok) return null; + + const data = await res.json(); + return data.records?.[0]?.fields?.name ?? null; +} + +export async function generateMetadata( + props: Props +): Promise { + const params = await props.params; + const hospitalName = await getHospitalName(params.id); + + return { + title: hospitalName + ? `${hospitalName} – Urgences` + : `Détail de l'hôpital`, + description: hospitalName + ? `Consultez les informations de l'hôpital ${hospitalName}.` + : `Consultez les détails de l'hôpital.`, + }; +} + +export default function HospitalLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/client/app/hopitaux/layout.tsx b/client/app/hopitaux/layout.tsx new file mode 100644 index 0000000..8e2c0bf --- /dev/null +++ b/client/app/hopitaux/layout.tsx @@ -0,0 +1,23 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Liste des hôpitaux', + description: 'Consultez la liste des hôpitaux avec services d\'urgence les plus proches de votre position.', +} + +export default function HopitauxLayout({ + children, + }: { + children: React.ReactNode + }) { + return ( + + + + + + {children} + + + ) +} \ No newline at end of file diff --git a/client/app/layout.tsx b/client/app/layout.tsx index 67c8841..30f9e58 100644 --- a/client/app/layout.tsx +++ b/client/app/layout.tsx @@ -1,8 +1,11 @@ -import type { Metadata } from 'next' import './globals.scss' +import { Metadata } from 'next'; export const metadata: Metadata = { - title: 'Quelles Urgences', + title: { + default: 'Quelles Urgences', + template: '%s | Quelles Urgences', + }, description: 'Application de gestion des urgences', icons: { icon: '/images/logo/logo-red.svg', diff --git a/client/app/map/layout.tsx b/client/app/map/layout.tsx new file mode 100644 index 0000000..2c93c9b --- /dev/null +++ b/client/app/map/layout.tsx @@ -0,0 +1,23 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Carte des urgences', + description: 'Visualisez les hôpitaux avec services d\'urgence les plus proches de votre position.', +} + +export default function MapLayout({ + children, + }: { + children: React.ReactNode + }) { + return ( + + + + + + {children} + + + ) +} From 805b3e06ac8c87d5fb6cd1e955d8e067c241a826 Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 11:07:41 +0100 Subject: [PATCH 04/13] fix: issus with layout --- client/app/hopitaux/layout.tsx | 11 +---------- client/app/map/layout.tsx | 11 +---------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/client/app/hopitaux/layout.tsx b/client/app/hopitaux/layout.tsx index 8e2c0bf..8a7a02d 100644 --- a/client/app/hopitaux/layout.tsx +++ b/client/app/hopitaux/layout.tsx @@ -10,14 +10,5 @@ export default function HopitauxLayout({ }: { children: React.ReactNode }) { - return ( - - - - - - {children} - - - ) + return <>{children}; } \ No newline at end of file diff --git a/client/app/map/layout.tsx b/client/app/map/layout.tsx index 2c93c9b..b990b6f 100644 --- a/client/app/map/layout.tsx +++ b/client/app/map/layout.tsx @@ -10,14 +10,5 @@ export default function MapLayout({ }: { children: React.ReactNode }) { - return ( - - - - - - {children} - - - ) + return <>{children}; } From c2715fc7a69a4282d00a3d6e9d7c2d19572973e9 Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 11:10:31 +0100 Subject: [PATCH 05/13] feat: 3/4 base security --- client/next.config.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/client/next.config.js b/client/next.config.js index 4f19f47..e5e87e4 100644 --- a/client/next.config.js +++ b/client/next.config.js @@ -4,6 +4,27 @@ const nextConfig = { images: { qualities: [75, 100], }, + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'X-Frame-Options', + value: 'DENY', + }, + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + ], + }, + ]; + }, } export default nextConfig \ No newline at end of file From 638e7430325e66881c7748beba68fa369cbc1426 Mon Sep 17 00:00:00 2001 From: kao-outar Date: Fri, 30 Jan 2026 11:17:50 +0100 Subject: [PATCH 06/13] feat(map): center map on hospital in detail page Add initialCenter, initialZoom and focusRecordId props to enable dynamic map positioning and auto-popup on hospital detail pages --- client/app/hopitaux/[id]/page.tsx | 30 +++- client/components/MapComponent.tsx | 271 +++++++++++------------------ client/components/MapWrapper.tsx | 14 +- 3 files changed, 145 insertions(+), 170 deletions(-) diff --git a/client/app/hopitaux/[id]/page.tsx b/client/app/hopitaux/[id]/page.tsx index 58695ac..7c0552d 100644 --- a/client/app/hopitaux/[id]/page.tsx +++ b/client/app/hopitaux/[id]/page.tsx @@ -168,6 +168,30 @@ export default function HospitalDetailPage({ params }: { params: Promise<{ id: s ); } + const hospitalCenter: [number, number] | null = (() => { + const fields = hospital.fields; + + if (fields.meta_geo_point && Array.isArray(fields.meta_geo_point)) { + const [lat, lon] = fields.meta_geo_point; + if (typeof lat === 'number' && typeof lon === 'number') { + return [lat, lon]; + } + } + + if (fields.geometry?.coordinates && Array.isArray(fields.geometry.coordinates)) { + const [lon, lat] = fields.geometry.coordinates; + if (typeof lat === 'number' && typeof lon === 'number') { + return [lat, lon]; + } + } + + if (fields.lat && fields.lon) { + return [fields.lat, fields.lon]; + } + + return null; + })(); + return ( <>
@@ -204,7 +228,11 @@ export default function HospitalDetailPage({ params }: { params: Promise<{ id: s

Localisation

- +
{placeAddress && (
diff --git a/client/components/MapComponent.tsx b/client/components/MapComponent.tsx index e6c11ed..21dce5c 100644 --- a/client/components/MapComponent.tsx +++ b/client/components/MapComponent.tsx @@ -3,8 +3,43 @@ import { useEffect, useRef } from 'react' import dynamic from 'next/dynamic' import type { Map as LeafletMap, Marker as LeafletMarker } from 'leaflet' -import { getHospitals } from '@/app/api/hospitals/route' -import type { Hospital } from '@/types/api' +import type { ComponentType } from 'react' + +interface Hospital { + recordid: string + fields: { + name: string + phone?: string + dist?: string + meta_geo_point?: [number, number] | number[] + geometry?: { + coordinates?: [number, number] | number[] + } + lat?: number + lon?: number + } & Record +} + +async function getHospitals(latitude: number, longitude: number): Promise { + try { + const baseUrl = process.env.NEXT_PUBLIC_HOSPITALS_API_URL + const radius = process.env.NEXT_PUBLIC_SEARCH_RADIUS + const apiUrl = `${baseUrl}&geofilter.distance=${latitude},${longitude},${radius}` + + const res = await fetch(apiUrl, { cache: 'no-store' }) + + if (!res.ok) { + console.error(`Failed to fetch hospitals: ${res.status} ${res.statusText}`) + return [] + } + + const data = await res.json() + return data.records as Hospital[] + } catch (error) { + console.error('Error fetching hospitals:', error) + return [] + } +} const extractCoordinates = (hospital: Hospital): [number, number] | null => { const fields = hospital.fields @@ -32,13 +67,16 @@ const extractCoordinates = (hospital: Hospital): [number, number] | null => { interface MapContentProps { fullScreen?: boolean + initialCenter?: [number, number] + initialZoom?: number + focusRecordId?: string } const PARIS_COORDS: [number, number] = [48.8566, 2.3522] const DEFAULT_ZOOM = 13 const PARIS_FALLBACK_ZOOM = 12 -function MapContent({ fullScreen = false }: MapContentProps) { +function MapContent({ fullScreen = false, initialCenter, initialZoom, focusRecordId }: MapContentProps) { const mapRef = useRef(null) const mapInstanceRef = useRef(null) const markersRef = useRef([]) @@ -109,7 +147,9 @@ function MapContent({ fullScreen = false }: MapContentProps) { ;(container as any)._leaflet_id = null - const map = L.map(container).setView(PARIS_COORDS, PARIS_FALLBACK_ZOOM) + const map = L.map(container, { + keyboard: false + }).setView(PARIS_COORDS, PARIS_FALLBACK_ZOOM) L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', @@ -148,18 +188,6 @@ function MapContent({ fullScreen = false }: MapContentProps) { controlLinks.forEach((link) => { (link as HTMLElement).setAttribute('tabindex', '-1') }) - - // Désactiver le focus sur les clusters de marqueurs - const clusterMarkers = container.querySelectorAll('.marker-cluster, .marker-cluster-small, .marker-cluster-medium, .marker-cluster-large') - clusterMarkers.forEach((cluster) => { - (cluster as HTMLElement).setAttribute('tabindex', '-1') - }) - - // Désactiver le focus sur tous les marqueurs Leaflet - const allMarkers = container.querySelectorAll('.leaflet-marker-icon') - allMarkers.forEach((marker) => { - (marker as HTMLElement).setAttribute('tabindex', '-1') - }) } const timers: NodeJS.Timeout[] = [] @@ -172,145 +200,42 @@ function MapContent({ fullScreen = false }: MapContentProps) { subtree: true }) - const formatDistance = (lat: number, lng: number): string | null => { - const userPos = userPositionRef.current - - const fromUser = - userPos != null - ? (() => { - const R = 6371e3 // metres - const toRad = (deg: number) => (deg * Math.PI) / 180 - const φ1 = toRad(userPos[0]) - const φ2 = toRad(lat) - const Δφ = toRad(lat - userPos[0]) - const Δλ = toRad(lng - userPos[1]) - - 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 - })() - : null - - if (fromUser == null || Number.isNaN(fromUser)) return null - - if (fromUser >= 1000) { - const km = fromUser / 1000 - return `${km.toFixed(1)} km` - } - - const rounded = Math.round(fromUser / 50) * 50 - return `${rounded} m` - } - - const getPhone = (fields: Hospital['fields']): string | null => { - const anyFields = fields as Record - const phone = - (anyFields.phone as string | undefined) || - (anyFields.telephone as string | undefined) || - (anyFields.tel as string | undefined) - if (!phone) return null - return phone.trim() - } - - const createPopupHTML = (hospital: Hospital, lat: number, lng: number): string => { + const createPopupHTML = (hospitalName: string, lat: number, lng: number): string => { const userPos = userPositionRef.current let itineraryUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}` if (userPos) { itineraryUrl += `&origin=${userPos[0]},${userPos[1]}` } - - const distanceLabel = formatDistance(lat, lng) - const phone = getPhone(hospital.fields) - const phoneHref = phone ? `tel:${phone.replace(/\s+/g, '')}` : null - const hospitalName = hospital.fields.name - + return ` -
-
+
+
${hospitalName}
- ${ - distanceLabel - ? `
- ${distanceLabel} -
` - : '' - } -
- ${ - phone - ? ` - - 📞 - - ${phone} - ` - : '' - } - - Itinéraire - -
+ + Itinéraire +
` } @@ -324,7 +249,7 @@ function MapContent({ fullScreen = false }: MapContentProps) { }) if (marker) { - const newPopupContent = createPopupHTML(hospital, lat, lng) + const newPopupContent = createPopupHTML(hospital.fields.name, lat, lng) marker.setPopupContent(newPopupContent) } }) @@ -344,7 +269,8 @@ function MapContent({ fullScreen = false }: MapContentProps) { return } - let markersAdded = 0 + let focusMarker: LeafletMarker | null = null + let focusCoords: [number, number] | null = null hospitalsDataRef.current = [] @@ -357,27 +283,17 @@ function MapContent({ fullScreen = false }: MapContentProps) { const [lat, lng] = coords hospitalsDataRef.current.push({ hospital, coords }) - const popupContent = createPopupHTML(hospital, lat, lng) + const popupContent = createPopupHTML(hospital.fields.name, lat, lng) if (!mapInstanceRef.current) return - const marker = L.marker([lat, lng], { - icon: redIcon - }) + const marker = L.marker([lat, lng], { icon: redIcon }) .bindPopup(popupContent, { className: 'hospital-popup', closeButton: true, autoClose: false, closeOnClick: false, }) - .on('popupopen', () => { - setTimeout(() => { - const closeButton = document.querySelector('.leaflet-popup-close-button') as HTMLElement - if (closeButton) { - closeButton.setAttribute('tabindex', '-1') - } - }, 0) - }) const handleMouseOver = () => { marker.openPopup() @@ -390,8 +306,19 @@ function MapContent({ fullScreen = false }: MapContentProps) { } markersRef.current.push(marker) - markersAdded++ + + if (focusRecordId && hospital.recordid === focusRecordId) { + focusMarker = marker + focusCoords = [lat, lng] + } }) + + if (focusMarker && focusCoords && mapInstanceRef.current) { + const zoomToUse = initialZoom ?? 16 + mapInstanceRef.current.setView(focusCoords, zoomToUse) + mapInitializedRef.current = true + ;(focusMarker as LeafletMarker).openPopup() + } } catch (error) { console.error('Error loading hospitals:', error) } @@ -416,7 +343,15 @@ function MapContent({ fullScreen = false }: MapContentProps) { } } - if ("geolocation" in navigator) { + if (initialCenter && isValidCoordinates(initialCenter[0], initialCenter[1])) { + userPositionRef.current = null + if (!focusRecordId) { + const zoomToUse = initialZoom ?? DEFAULT_ZOOM + centerMapOnCoords(initialCenter, zoomToUse) + } + await loadHospitals(initialCenter[0], initialCenter[1]) + updatePopups() + } else if ("geolocation" in navigator) { navigator.geolocation.getCurrentPosition( async (position) => { const { latitude, longitude } = position.coords @@ -436,8 +371,7 @@ function MapContent({ fullScreen = false }: MapContentProps) { } userMarkerRef.current = L.marker(PARIS_COORDS, { icon: userIcon, - zIndexOffset: 1000, - keyboard: false + zIndexOffset: 1000 }) .addTo(map) .bindPopup('Votre position (approximative)', { closeButton: false }) @@ -454,8 +388,7 @@ function MapContent({ fullScreen = false }: MapContentProps) { } userMarkerRef.current = L.marker([latitude, longitude], { icon: userIcon, - zIndexOffset: 1000, - keyboard: false + zIndexOffset: 1000 }) .addTo(map) .bindPopup('Votre position', { closeButton: false }) @@ -588,6 +521,10 @@ function MapContent({ fullScreen = false }: MapContentProps) { ) } +interface MapComponentProps { + fullScreen?: boolean +} + const MapComponent = dynamic(() => Promise.resolve(MapContent), { ssr: false, loading: () => ( @@ -595,6 +532,6 @@ const MapComponent = dynamic(() => Promise.resolve(MapContent), {

Chargement de la carte...

) -}) +}) as ComponentType export default MapComponent \ No newline at end of file diff --git a/client/components/MapWrapper.tsx b/client/components/MapWrapper.tsx index c8f1007..d56b22e 100644 --- a/client/components/MapWrapper.tsx +++ b/client/components/MapWrapper.tsx @@ -13,8 +13,18 @@ const MapComponent = dynamic(() => import('./MapComponent'), { interface MapWrapperProps { fullScreen?: boolean + initialCenter?: [number, number] + initialZoom?: number + focusRecordId?: string } -export default function MapWrapper({ fullScreen = false }: MapWrapperProps) { - return +export default function MapWrapper({ fullScreen = false, initialCenter, initialZoom, focusRecordId }: MapWrapperProps) { + return ( + + ) } \ No newline at end of file From b2f0ead0b0a993dbc99c22a0f9cc60a8d5334688 Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 09:36:16 +0100 Subject: [PATCH 07/13] feat: add font Arial for everything --- client/app/globals.scss | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/app/globals.scss b/client/app/globals.scss index 4c9bf11..be07a3f 100644 --- a/client/app/globals.scss +++ b/client/app/globals.scss @@ -15,9 +15,7 @@ body { } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-family: "Arial", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } From db0907fbf49acaee881d6a12f032f8a963fd537c Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 09:54:16 +0100 Subject: [PATCH 08/13] refactor: clean architecture --- client/app/hopitaux/[id]/page.tsx | 6 ++---- client/app/hopitaux/page.tsx | 2 +- client/app/page.tsx | 3 +-- client/components/SearchBar.tsx | 1 - client/{app/components => components/home}/FAQSection.tsx | 0 .../components => components/hopitaux}/HospitalCard.tsx | 0 .../components => components/hopitaux}/HospitalList.tsx | 2 +- 7 files changed, 5 insertions(+), 9 deletions(-) rename client/{app/components => components/home}/FAQSection.tsx (100%) rename client/{app/hopitaux/components => components/hopitaux}/HospitalCard.tsx (100%) rename client/{app/hopitaux/components => components/hopitaux}/HospitalList.tsx (90%) diff --git a/client/app/hopitaux/[id]/page.tsx b/client/app/hopitaux/[id]/page.tsx index 7c0552d..88f86bf 100644 --- a/client/app/hopitaux/[id]/page.tsx +++ b/client/app/hopitaux/[id]/page.tsx @@ -361,10 +361,8 @@ export default function HospitalDetailPage({ params }: { params: Promise<{ id: s })()} {!mockData && !accessibilityOptions && ( -
-

- Les spécifications de cet établissement ne sont pas encore disponibles. -

+
+
)}
diff --git a/client/app/hopitaux/page.tsx b/client/app/hopitaux/page.tsx index 0710cc4..781e2e0 100644 --- a/client/app/hopitaux/page.tsx +++ b/client/app/hopitaux/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from 'react'; import Header from '@/components/Header'; -import HospitalList from './components/HospitalList'; +import HospitalList from '@/components/hopitaux/HospitalList'; import SearchBar from '@/components/SearchBar'; import MultiSelectFilter from '@/components/MultiSelectFilter'; import Loading from '@/components/Loading'; diff --git a/client/app/page.tsx b/client/app/page.tsx index 77dce60..4a57977 100644 --- a/client/app/page.tsx +++ b/client/app/page.tsx @@ -1,8 +1,7 @@ import Image from 'next/image' import Link from 'next/link'; import Header from '@/components/Header' -import MapWrapper from '@/components/MapWrapper' -import FAQSection from '@/app/components/FAQSection' +import FAQSection from '@/components/home/FAQSection' export default function Home() { return ( diff --git a/client/components/SearchBar.tsx b/client/components/SearchBar.tsx index f4d54fe..128e1e1 100644 --- a/client/components/SearchBar.tsx +++ b/client/components/SearchBar.tsx @@ -40,7 +40,6 @@ export default function SearchBar({ "rounded-full", "px-6 py-3", "shadow-sm", - "font-[Arial]", "focus-within:ring-4 focus-within:ring-red-600", className, ].join(" ")} diff --git a/client/app/components/FAQSection.tsx b/client/components/home/FAQSection.tsx similarity index 100% rename from client/app/components/FAQSection.tsx rename to client/components/home/FAQSection.tsx diff --git a/client/app/hopitaux/components/HospitalCard.tsx b/client/components/hopitaux/HospitalCard.tsx similarity index 100% rename from client/app/hopitaux/components/HospitalCard.tsx rename to client/components/hopitaux/HospitalCard.tsx diff --git a/client/app/hopitaux/components/HospitalList.tsx b/client/components/hopitaux/HospitalList.tsx similarity index 90% rename from client/app/hopitaux/components/HospitalList.tsx rename to client/components/hopitaux/HospitalList.tsx index f7f080a..0abcfdc 100644 --- a/client/app/hopitaux/components/HospitalList.tsx +++ b/client/components/hopitaux/HospitalList.tsx @@ -1,4 +1,4 @@ -import HospitalCard from "./HospitalCard"; +import HospitalCard from "@/components/hopitaux/HospitalCard"; import { HospitalWithMock } from "@/types/api"; import NotFoundData from "@/components/NotFoundData"; From 8b906528c9c6b6379c7ffd11283bac59bed18801 Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 10:42:27 +0100 Subject: [PATCH 09/13] feat: add metadata for SEO --- client/app/hopitaux/[id]/layout.tsx | 46 +++++++++++++++++++++++++++++ client/app/hopitaux/layout.tsx | 23 +++++++++++++++ client/app/layout.tsx | 7 +++-- client/app/map/layout.tsx | 23 +++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 client/app/hopitaux/[id]/layout.tsx create mode 100644 client/app/hopitaux/layout.tsx create mode 100644 client/app/map/layout.tsx diff --git a/client/app/hopitaux/[id]/layout.tsx b/client/app/hopitaux/[id]/layout.tsx new file mode 100644 index 0000000..4038dce --- /dev/null +++ b/client/app/hopitaux/[id]/layout.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from 'next'; + +type Props = { + params: Promise<{ id: string }>; +}; + +async function getHospitalName(id: string): Promise { + const baseUrl = process.env.NEXT_PUBLIC_HOSPITALS_SINGLE_API_URL; + + if (!baseUrl) { + console.error('NEXT_PUBLIC_HOSPITALS_SINGLE_API_URL manquant'); + return null; + } + + const apiUrl = `${baseUrl}&rows=1&q=recordid:${id}`; + const res = await fetch(apiUrl, { next: { revalidate: 3600 } }); + + if (!res.ok) return null; + + const data = await res.json(); + return data.records?.[0]?.fields?.name ?? null; +} + +export async function generateMetadata( + props: Props +): Promise { + const params = await props.params; + const hospitalName = await getHospitalName(params.id); + + return { + title: hospitalName + ? `${hospitalName} – Urgences` + : `Détail de l'hôpital`, + description: hospitalName + ? `Consultez les informations de l'hôpital ${hospitalName}.` + : `Consultez les détails de l'hôpital.`, + }; +} + +export default function HospitalLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/client/app/hopitaux/layout.tsx b/client/app/hopitaux/layout.tsx new file mode 100644 index 0000000..8e2c0bf --- /dev/null +++ b/client/app/hopitaux/layout.tsx @@ -0,0 +1,23 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Liste des hôpitaux', + description: 'Consultez la liste des hôpitaux avec services d\'urgence les plus proches de votre position.', +} + +export default function HopitauxLayout({ + children, + }: { + children: React.ReactNode + }) { + return ( + + + + + + {children} + + + ) +} \ No newline at end of file diff --git a/client/app/layout.tsx b/client/app/layout.tsx index 67c8841..30f9e58 100644 --- a/client/app/layout.tsx +++ b/client/app/layout.tsx @@ -1,8 +1,11 @@ -import type { Metadata } from 'next' import './globals.scss' +import { Metadata } from 'next'; export const metadata: Metadata = { - title: 'Quelles Urgences', + title: { + default: 'Quelles Urgences', + template: '%s | Quelles Urgences', + }, description: 'Application de gestion des urgences', icons: { icon: '/images/logo/logo-red.svg', diff --git a/client/app/map/layout.tsx b/client/app/map/layout.tsx new file mode 100644 index 0000000..2c93c9b --- /dev/null +++ b/client/app/map/layout.tsx @@ -0,0 +1,23 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Carte des urgences', + description: 'Visualisez les hôpitaux avec services d\'urgence les plus proches de votre position.', +} + +export default function MapLayout({ + children, + }: { + children: React.ReactNode + }) { + return ( + + + + + + {children} + + + ) +} From ae9756e6f7939c50bff72743d5e97bf10c7a5116 Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 11:07:41 +0100 Subject: [PATCH 10/13] fix: issus with layout --- client/app/hopitaux/layout.tsx | 11 +---------- client/app/map/layout.tsx | 11 +---------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/client/app/hopitaux/layout.tsx b/client/app/hopitaux/layout.tsx index 8e2c0bf..8a7a02d 100644 --- a/client/app/hopitaux/layout.tsx +++ b/client/app/hopitaux/layout.tsx @@ -10,14 +10,5 @@ export default function HopitauxLayout({ }: { children: React.ReactNode }) { - return ( - - - - - - {children} - - - ) + return <>{children}; } \ No newline at end of file diff --git a/client/app/map/layout.tsx b/client/app/map/layout.tsx index 2c93c9b..b990b6f 100644 --- a/client/app/map/layout.tsx +++ b/client/app/map/layout.tsx @@ -10,14 +10,5 @@ export default function MapLayout({ }: { children: React.ReactNode }) { - return ( - - - - - - {children} - - - ) + return <>{children}; } From 8d8fd61e972394b086d07d2207fa1b84baace154 Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 11:10:31 +0100 Subject: [PATCH 11/13] feat: 3/4 base security --- client/next.config.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/client/next.config.js b/client/next.config.js index 4f19f47..e5e87e4 100644 --- a/client/next.config.js +++ b/client/next.config.js @@ -4,6 +4,27 @@ const nextConfig = { images: { qualities: [75, 100], }, + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'X-Frame-Options', + value: 'DENY', + }, + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + ], + }, + ]; + }, } export default nextConfig \ No newline at end of file From cadc640d489ddf6b8f997625fa57e86f94b0c471 Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 12:00:52 +0100 Subject: [PATCH 12/13] refactor: refactor if by switch --- client/app/hopitaux/page.tsx | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/client/app/hopitaux/page.tsx b/client/app/hopitaux/page.tsx index 781e2e0..88f9da8 100644 --- a/client/app/hopitaux/page.tsx +++ b/client/app/hopitaux/page.tsx @@ -128,26 +128,22 @@ export default function HopitauxPage() { if (selectedSpecifications.length > 0) { filtered = filtered.filter(hospital => { return selectedSpecifications.every(spec => { - if (spec === 'fire_fighter') { - return hospital.mockData?.fire_fighter; + switch (spec) { + case 'fire_fighter': + return hospital.mockData?.fire_fighter; + case 'social_worker': + return hospital.mockData?.social_worker; + case 'wheelchairAccessibleEntrance': + return hospital.accessibilityOptions?.wheelchairAccessibleEntrance; + case 'wheelchairAccessibleParking': + return hospital.accessibilityOptions?.wheelchairAccessibleParking; + case 'wheelchairAccessibleRestroom': + return hospital.accessibilityOptions?.wheelchairAccessibleRestroom; + case 'wheelchairAccessibleSeating': + return hospital.accessibilityOptions?.wheelchairAccessibleSeating; + default: + return false; } - if (spec === 'social_worker') { - return hospital.mockData?.social_worker; - } - if (spec === 'wheelchairAccessibleEntrance') { - return hospital.accessibilityOptions?.wheelchairAccessibleEntrance; - } - if (spec === 'wheelchairAccessibleParking') { - return hospital.accessibilityOptions?.wheelchairAccessibleParking; - } - if (spec === 'wheelchairAccessibleRestroom') { - return hospital.accessibilityOptions?.wheelchairAccessibleRestroom; - } - if (spec === 'wheelchairAccessibleSeating') { - return hospital.accessibilityOptions?.wheelchairAccessibleSeating; - } - - return false; }); }); } From b440046201af303e04b676f364ea989d9cad5d6e Mon Sep 17 00:00:00 2001 From: Roland HUON Date: Fri, 30 Jan 2026 12:12:13 +0100 Subject: [PATCH 13/13] fix: restore design markeurs --- client/components/MapComponent.tsx | 213 ++++++++++++++++++++++------- 1 file changed, 163 insertions(+), 50 deletions(-) diff --git a/client/components/MapComponent.tsx b/client/components/MapComponent.tsx index 21dce5c..dd9555d 100644 --- a/client/components/MapComponent.tsx +++ b/client/components/MapComponent.tsx @@ -93,7 +93,7 @@ function MapContent({ fullScreen = false, initialCenter, initialZoom, focusRecor const container = mapRef.current const initMap = async () => { - if (mapInstanceRef.current) return undefined + if (mapInstanceRef.current) return const L = (await import('leaflet')).default await import('leaflet.markercluster') @@ -178,21 +178,31 @@ function MapContent({ fullScreen = false, initialCenter, initialZoom, focusRecor map.addLayer(markerClusterGroup) markerClusterGroupRef.current = markerClusterGroup + // Fonction pour désactiver le focus sur les contrôles et clusters const disableMapFocus = () => { - const mapContainer = container.querySelector('.leaflet-container') as HTMLElement - if (mapContainer) { - mapContainer.setAttribute('tabindex', '-1') - } - + // Désactiver le focus sur les contrôles de la carte (zoom, etc.) const controlLinks = container.querySelectorAll('.leaflet-control a') controlLinks.forEach((link) => { (link as HTMLElement).setAttribute('tabindex', '-1') }) + + // Désactiver le focus sur les clusters + const clusters = container.querySelectorAll('.marker-cluster') + clusters.forEach((cluster) => { + (cluster as HTMLElement).setAttribute('tabindex', '-1') + }) + + // Désactiver le focus sur tous les marqueurs + const markers = container.querySelectorAll('.leaflet-marker-icon') + markers.forEach((marker) => { + (marker as HTMLElement).setAttribute('tabindex', '-1') + }) } const timers: NodeJS.Timeout[] = [] timers.push(setTimeout(disableMapFocus, 100)) timers.push(setTimeout(disableMapFocus, 500)) + timers.push(setTimeout(disableMapFocus, 1000)) const observer = new MutationObserver(disableMapFocus) observer.observe(container, { @@ -200,42 +210,144 @@ function MapContent({ fullScreen = false, initialCenter, initialZoom, focusRecor subtree: true }) - const createPopupHTML = (hospitalName: string, lat: number, lng: number): string => { + const formatDistance = (lat: number, lng: number): string | null => { + const userPos = userPositionRef.current + + const fromUser = + userPos != null + ? (() => { + const R = 6371e3 // metres + const toRad = (deg: number) => (deg * Math.PI) / 180 + const φ1 = toRad(userPos[0]) + const φ2 = toRad(lat) + const Δφ = toRad(lat - userPos[0]) + const Δλ = toRad(lng - userPos[1]) + + 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 + })() + : null + + if (fromUser == null || Number.isNaN(fromUser)) return null + + if (fromUser >= 1000) { + const km = fromUser / 1000 + return `${km.toFixed(1)} km` + } + + const rounded = Math.round(fromUser / 50) * 50 + return `${rounded} m` + } + + const getPhone = (fields: Hospital['fields']): string | null => { + const anyFields = fields as Record + const phone = + (anyFields.phone as string | undefined) || + (anyFields.telephone as string | undefined) || + (anyFields.tel as string | undefined) + if (!phone) return null + return phone.trim() + } + + const createPopupHTML = (hospital: Hospital, lat: number, lng: number): string => { const userPos = userPositionRef.current let itineraryUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}` if (userPos) { itineraryUrl += `&origin=${userPos[0]},${userPos[1]}` } + const distanceLabel = formatDistance(lat, lng) + const phone = getPhone(hospital.fields) + const phoneHref = phone ? `tel:${phone.replace(/\s+/g, '')}` : null + const hospitalName = hospital.fields.name return ` -
-
+
+
${hospitalName}
- - Itinéraire - + ${ + distanceLabel + ? `
+ ${distanceLabel} +
` + : '' + } +
+ ${ + phone + ? ` + + 📞 + + ${phone} + ` + : '' + } + + Itinéraire + +
` } @@ -249,7 +361,7 @@ function MapContent({ fullScreen = false, initialCenter, initialZoom, focusRecor }) if (marker) { - const newPopupContent = createPopupHTML(hospital.fields.name, lat, lng) + const newPopupContent = createPopupHTML(hospital, lat, lng) marker.setPopupContent(newPopupContent) } }) @@ -283,17 +395,27 @@ function MapContent({ fullScreen = false, initialCenter, initialZoom, focusRecor const [lat, lng] = coords hospitalsDataRef.current.push({ hospital, coords }) - const popupContent = createPopupHTML(hospital.fields.name, lat, lng) + const popupContent = createPopupHTML(hospital, lat, lng) if (!mapInstanceRef.current) return - const marker = L.marker([lat, lng], { icon: redIcon }) + const marker = L.marker([lat, lng], { + icon: redIcon, + keyboard: false + }) .bindPopup(popupContent, { className: 'hospital-popup', closeButton: true, autoClose: false, closeOnClick: false, }) + .on('popupopen', () => { + // Désactiver le focus sur le bouton de fermeture du popup + const closeButton = container.querySelector('.leaflet-popup-close-button') as HTMLElement + if (closeButton) { + closeButton.setAttribute('tabindex', '-1') + } + }) const handleMouseOver = () => { marker.openPopup() @@ -371,7 +493,8 @@ function MapContent({ fullScreen = false, initialCenter, initialZoom, focusRecor } userMarkerRef.current = L.marker(PARIS_COORDS, { icon: userIcon, - zIndexOffset: 1000 + zIndexOffset: 1000, + keyboard: false }) .addTo(map) .bindPopup('Votre position (approximative)', { closeButton: false }) @@ -388,7 +511,8 @@ function MapContent({ fullScreen = false, initialCenter, initialZoom, focusRecor } userMarkerRef.current = L.marker([latitude, longitude], { icon: userIcon, - zIndexOffset: 1000 + zIndexOffset: 1000, + keyboard: false }) .addTo(map) .bindPopup('Votre position', { closeButton: false }) @@ -433,15 +557,10 @@ function MapContent({ fullScreen = false, initialCenter, initialZoom, focusRecor await loadHospitals(PARIS_COORDS[0], PARIS_COORDS[1]) } - // Retourner les ressources à nettoyer - return { observer, timers } + // Pas de return nécessaire } - let cleanupResources: { observer?: MutationObserver; timers?: NodeJS.Timeout[] } | undefined - - initMap().then((resources) => { - cleanupResources = resources - }) + initMap() return () => { markerEventHandlersRef.current.forEach((handlers, marker) => { @@ -465,12 +584,6 @@ function MapContent({ fullScreen = false, initialCenter, initialZoom, focusRecor mapInstanceRef.current.remove() mapInstanceRef.current = null } - if (cleanupResources?.observer) { - cleanupResources.observer.disconnect() - } - if (cleanupResources?.timers) { - cleanupResources.timers.forEach(timer => clearTimeout(timer)) - } } }, [])