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; } 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/[id]/page.tsx b/client/app/hopitaux/[id]/page.tsx index 58695ac..88f86bf 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 && (
@@ -333,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/layout.tsx b/client/app/hopitaux/layout.tsx new file mode 100644 index 0000000..8a7a02d --- /dev/null +++ b/client/app/hopitaux/layout.tsx @@ -0,0 +1,14 @@ +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/hopitaux/page.tsx b/client/app/hopitaux/page.tsx index 0710cc4..88f9da8 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'; @@ -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; }); }); } 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..b990b6f --- /dev/null +++ b/client/app/map/layout.tsx @@ -0,0 +1,14 @@ +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}; +} 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/MapComponent.tsx b/client/components/MapComponent.tsx index e6c11ed..dd9555d 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([]) @@ -55,7 +93,7 @@ function MapContent({ fullScreen = false }: MapContentProps) { 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') @@ -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', @@ -138,26 +178,23 @@ function MapContent({ fullScreen = false }: MapContentProps) { 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 de marqueurs - const clusterMarkers = container.querySelectorAll('.marker-cluster, .marker-cluster-small, .marker-cluster-medium, .marker-cluster-large') - clusterMarkers.forEach((cluster) => { + // 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 Leaflet - const allMarkers = container.querySelectorAll('.leaflet-marker-icon') - allMarkers.forEach((marker) => { + // Désactiver le focus sur tous les marqueurs + const markers = container.querySelectorAll('.leaflet-marker-icon') + markers.forEach((marker) => { (marker as HTMLElement).setAttribute('tabindex', '-1') }) } @@ -165,6 +202,7 @@ function MapContent({ fullScreen = false }: MapContentProps) { 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, { @@ -221,12 +259,11 @@ function MapContent({ fullScreen = false }: MapContentProps) { 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 `
{ const { latitude, longitude } = position.coords @@ -500,15 +557,10 @@ function MapContent({ fullScreen = false }: MapContentProps) { 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) => { @@ -532,12 +584,6 @@ function MapContent({ fullScreen = false }: MapContentProps) { mapInstanceRef.current.remove() mapInstanceRef.current = null } - if (cleanupResources?.observer) { - cleanupResources.observer.disconnect() - } - if (cleanupResources?.timers) { - cleanupResources.timers.forEach(timer => clearTimeout(timer)) - } } }, []) @@ -588,6 +634,10 @@ function MapContent({ fullScreen = false }: MapContentProps) { ) } +interface MapComponentProps { + fullScreen?: boolean +} + const MapComponent = dynamic(() => Promise.resolve(MapContent), { ssr: false, loading: () => ( @@ -595,6 +645,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 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"; 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