diff --git a/public/images/train_agc.png b/public/images/train_agc.png new file mode 100644 index 0000000..ea1bc87 Binary files /dev/null and b/public/images/train_agc.png differ diff --git a/src/app/[region]/horaires/page.tsx b/src/app/[region]/horaires/page.tsx new file mode 100644 index 0000000..dae15c6 --- /dev/null +++ b/src/app/[region]/horaires/page.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { supabase } from '@/lib/supabase'; +import HorairesPageClient from '@/components/HorairesPageClient'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +export default async function RegionHorairesPage({ + params, +}: { + params: Promise<{ region: string }>; +}) { + const { region } = await params; + + const regionCandidates = Array.from(new Set([ + region, + region === 'sud-provence-alpes-cote-d-azur' ? 'provence-alpes-cote-azur' : '', + region === 'provence-alpes-cote-azur' ? 'sud-provence-alpes-cote-d-azur' : '', + ].filter(Boolean))); + + // Fetch parameters + const { data } = await supabase + .from('parametres') + .select('valeur') + .eq('cle', 'general') + .single(); + + let regionData = { + nom_site: `FERROVIA ${region.replace(/-/g, ' ').toUpperCase()}`, + logo_header_url: '', + description_site: '', + }; + + if (data && data.valeur) { + const val = data.valeur as any; + let matchedRegionKey = regionCandidates.find(k => val.regions && val.regions[k]) || regionCandidates[0]; + const rObj = val.regions?.[matchedRegionKey] || {}; + + const logoUrl = rObj.logo_header_url + || val.logo_header_url?.[matchedRegionKey] + || val.logo_header_url_by_region?.[matchedRegionKey] + || val.logo_header_by_region?.[matchedRegionKey] + || ''; + + const siteName = rObj.nom_site + || val.nom_site_by_region?.[matchedRegionKey] + || `FERROVIA ${region.replace(/-/g, ' ').toUpperCase()}`; + + regionData = { + nom_site: siteName, + logo_header_url: logoUrl, + description_site: rObj.description_site || '', + }; + } + + // Fetch menus_regionaux + let menuStructure: any = null; + try { + const { data: menuData } = await supabase + .from('menus_regionaux') + .select('*') + .eq('actif', true) + .in('region_id', regionCandidates) + .limit(1); + if (menuData && menuData.length > 0) { + menuStructure = menuData[0].structure; + } + } catch (e) { + console.error('Error fetching menus_regionaux:', e); + } + + return ( + + ); +} diff --git a/src/app/[region]/offers/page.tsx b/src/app/[region]/offers/page.tsx new file mode 100644 index 0000000..83b5460 --- /dev/null +++ b/src/app/[region]/offers/page.tsx @@ -0,0 +1,52 @@ +import React, { Suspense } from 'react'; +import { notFound } from 'next/navigation'; +import { supabase } from '@/lib/supabase'; +import OffersPageClient from '@/components/OffersPageClient'; + +interface RegionOffersPageProps { + params: Promise<{ + region: string; + }>; +} + +export default async function RegionOffersPage({ params }: RegionOffersPageProps) { + const { region } = await params; + let menuStructure = null; + + try { + const { data: regionData } = await supabase + .from('regions') + .select('id, nom') + .eq('code_slug', region) + .maybeSingle(); + + if (regionData?.id) { + const { data: menuData } = await supabase + .from('menus_regionaux') + .select('structure') + .eq('region_id', regionData.id) + .maybeSingle(); + + if (menuData?.structure) { + menuStructure = typeof menuData.structure === 'string' + ? JSON.parse(menuData.structure) + : menuData.structure; + } + } + } catch (err) { + console.error(`Error loading menu for region ${region}:`, err); + } + + return ( + + Chargement des offres de billets... + + }> + + + ); +} diff --git a/src/app/[region]/page.tsx b/src/app/[region]/page.tsx index 12cc023..2db75fa 100644 --- a/src/app/[region]/page.tsx +++ b/src/app/[region]/page.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { supabase } from '@/lib/supabase'; -import RegionContent, { InfoTraficItem, ActualiteItem, HoraireItem } from '@/components/RegionContent'; +import RegionContent, { InfoTraficItem, ActualiteItem, EvenementItem, HoraireItem } from '@/components/RegionContent'; interface RegionData { nom_site: string; @@ -18,6 +18,13 @@ export default async function RegionPage({ }) { const { region } = await params; + // Region candidates for alias support + const regionCandidates = Array.from(new Set([ + region, + region === 'sud-provence-alpes-cote-d-azur' ? 'provence-alpes-cote-azur' : '', + region === 'provence-alpes-cote-azur' ? 'sud-provence-alpes-cote-d-azur' : '', + ].filter(Boolean))); + // 1. Fetch general settings const { data } = await supabase .from('parametres') @@ -33,15 +40,7 @@ export default async function RegionPage({ if (data && data.valeur) { const val = data.valeur as any; - - // Check direct region key or aliases (e.g. sud-provence-alpes-cote-d-azur <-> provence-alpes-cote-azur) - const candidates = [ - region, - region === 'sud-provence-alpes-cote-d-azur' ? 'provence-alpes-cote-azur' : '', - region === 'provence-alpes-cote-azur' ? 'sud-provence-alpes-cote-d-azur' : '', - ].filter(Boolean); - - let matchedRegionKey = candidates.find(k => val.regions && val.regions[k]) || candidates[0]; + let matchedRegionKey = regionCandidates.find(k => val.regions && val.regions[k]) || regionCandidates[0]; const rObj = val.regions?.[matchedRegionKey] || {}; @@ -66,13 +65,14 @@ export default async function RegionPage({ }; } - // 2. Fetch info trafic from Supabase table "info_trafic" + // 2. Fetch info trafic strictly filtered by region_id let infoTraficList: InfoTraficItem[] = []; try { const { data: trafficData } = await supabase .from('info_trafic') .select('*') .eq('actif', true) + .in('region_id', regionCandidates) .is('deleted_at', null) .order('created_at', { ascending: false }); if (trafficData) { @@ -82,13 +82,14 @@ export default async function RegionPage({ console.error('Error fetching info_trafic:', e); } - // 3. Fetch actualites from Supabase table "actualites" + // 3. Fetch actualites strictly filtered by region_id let actualitesList: ActualiteItem[] = []; try { const { data: actusData } = await supabase .from('actualites') .select('*') .eq('actif', true) + .in('region_id', regionCandidates) .is('deleted_at', null) .order('date_publication', { ascending: false }) .order('created_at', { ascending: false }); @@ -99,7 +100,40 @@ export default async function RegionPage({ console.error('Error fetching actualites:', e); } - // 4. Fetch gares and horaires from Supabase table "horaires" + // 4. Fetch evenements strictly filtered by region_id + let evenementsList: EvenementItem[] = []; + try { + const { data: eventsData } = await supabase + .from('evenements') + .select('*') + .eq('actif', true) + .in('region_id', regionCandidates) + .is('deleted_at', null) + .order('date_debut', { ascending: false }); + if (eventsData) { + evenementsList = eventsData as EvenementItem[]; + } + } catch (e) { + console.error('Error fetching evenements:', e); + } + + // 5. Fetch menus_regionaux strictly filtered by region_id + let menuStructure: any = null; + try { + const { data: menuData } = await supabase + .from('menus_regionaux') + .select('*') + .eq('actif', true) + .in('region_id', regionCandidates) + .limit(1); + if (menuData && menuData.length > 0) { + menuStructure = menuData[0].structure; + } + } catch (e) { + console.error('Error fetching menus_regionaux:', e); + } + + // 6. Fetch gares and horaires strictly filtered by region_id let horairesList: HoraireItem[] = []; try { const { data: garesData } = await supabase @@ -137,8 +171,9 @@ export default async function RegionPage({ regionData={regionData} initialInfoTrafic={infoTraficList} initialActualites={actualitesList} + initialEvenements={evenementsList} + initialMenuStructure={menuStructure} initialHoraires={horairesList} /> ); } - diff --git a/src/app/[region]/se-deplacer/horaires/page.tsx b/src/app/[region]/se-deplacer/horaires/page.tsx new file mode 100644 index 0000000..52d30e3 --- /dev/null +++ b/src/app/[region]/se-deplacer/horaires/page.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import RegionHorairesPage from '../../horaires/page'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +export default async function SeDeplacerHorairesPage({ + params, +}: { + params: Promise<{ region: string }>; +}) { + return ; +} diff --git a/src/app/[region]/se-deplacer/prochains-departs/page.tsx b/src/app/[region]/se-deplacer/prochains-departs/page.tsx new file mode 100644 index 0000000..e8c7fad --- /dev/null +++ b/src/app/[region]/se-deplacer/prochains-departs/page.tsx @@ -0,0 +1,49 @@ +import React, { Suspense } from 'react'; +import { supabase } from '@/lib/supabase'; +import ProchainsDepartsClient from '@/components/ProchainsDepartsClient'; + +interface RegionProchainsDepartsPageProps { + params: Promise<{ + region: string; + }>; +} + +export default async function RegionProchainsDepartsPage({ params }: RegionProchainsDepartsPageProps) { + const { region } = await params; + let menuStructure = null; + + try { + const { data: regionData } = await supabase + .from('regions') + .select('id, nom') + .or(`slug.eq.${region},code.eq.${region}`); + + const rIds = regionData ? regionData.map((r: any) => r.id) : []; + + let query = supabase.from('menus_regionaux').select('structure').eq('actif', true); + if (rIds.length > 0) { + query = query.in('region_id', rIds); + } + + const { data: menuData } = await query.limit(1); + + if (menuData && menuData.length > 0 && Array.isArray(menuData[0].structure)) { + menuStructure = menuData[0].structure; + } + } catch (err) { + console.error(`Error loading menu for region ${region}:`, err); + } + + return ( + + Chargement des prochains départs... + + }> + + + ); +} diff --git a/src/app/horaires/page.tsx b/src/app/horaires/page.tsx new file mode 100644 index 0000000..5688d64 --- /dev/null +++ b/src/app/horaires/page.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { supabase } from '@/lib/supabase'; +import HorairesPageClient from '@/components/HorairesPageClient'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +export default async function GlobalHorairesPage() { + const { data } = await supabase + .from('parametres') + .select('valeur') + .eq('cle', 'general') + .single(); + + let targetRegion = 'bourgogne-franche-comte'; + if (data && data.valeur) { + const val = data.valeur as any; + if (val.region) { + targetRegion = val.region; + } + } + + const regionCandidates = [targetRegion]; + + let regionData = { + nom_site: `FERROVIA ${targetRegion.replace(/-/g, ' ').toUpperCase()}`, + logo_header_url: '', + description_site: '', + }; + + if (data && data.valeur) { + const val = data.valeur as any; + const rObj = val.regions?.[targetRegion] || {}; + regionData = { + nom_site: rObj.nom_site || `FERROVIA ${targetRegion.replace(/-/g, ' ').toUpperCase()}`, + logo_header_url: rObj.logo_header_url || val.logo_header_url?.[targetRegion] || '', + description_site: rObj.description_site || '', + }; + } + + let menuStructure: any = null; + try { + const { data: menuData } = await supabase + .from('menus_regionaux') + .select('*') + .eq('actif', true) + .in('region_id', regionCandidates) + .limit(1); + if (menuData && menuData.length > 0) { + menuStructure = menuData[0].structure; + } + } catch (e) { + console.error('Error fetching menus_regionaux:', e); + } + + return ( + + ); +} diff --git a/src/app/offers/page.tsx b/src/app/offers/page.tsx new file mode 100644 index 0000000..fea1ce8 --- /dev/null +++ b/src/app/offers/page.tsx @@ -0,0 +1,44 @@ +import React, { Suspense } from 'react'; +import { supabase } from '@/lib/supabase'; +import OffersPageClient from '@/components/OffersPageClient'; + +export default async function OffersPage() { + let menuStructure = null; + + try { + const { data: regionData } = await supabase + .from('regions') + .select('id') + .eq('code_slug', 'bourgogne-franche-comte') + .maybeSingle(); + + if (regionData?.id) { + const { data: menuData } = await supabase + .from('menus_regionaux') + .select('structure') + .eq('region_id', regionData.id) + .maybeSingle(); + + if (menuData?.structure) { + menuStructure = typeof menuData.structure === 'string' + ? JSON.parse(menuData.structure) + : menuData.structure; + } + } + } catch (err) { + console.error('Error loading menu for offers:', err); + } + + return ( + + Chargement des offres de billets... + + }> + + + ); +} diff --git a/src/app/se-deplacer/prochains-departs/page.tsx b/src/app/se-deplacer/prochains-departs/page.tsx new file mode 100644 index 0000000..d47e446 --- /dev/null +++ b/src/app/se-deplacer/prochains-departs/page.tsx @@ -0,0 +1,41 @@ +import React, { Suspense } from 'react'; +import ProchainsDepartsClient from '@/components/ProchainsDepartsClient'; +import { supabase } from '@/lib/supabase'; + +export const metadata = { + title: 'Prochains départs | TER Ferrovia', + description: 'Tableaux des départs et arrivées en gare', +}; + +export default async function ProchainsDepartsPage() { + let menuStructure = null; + try { + const { data: mData } = await supabase + .from('menus_regionaux') + .select('structure') + .eq('actif', true) + .limit(1); + + if (mData && mData.length > 0 && Array.isArray(mData[0].structure)) { + menuStructure = mData[0].structure; + } + } catch (err) { + console.error('Error fetching menuStructure:', err); + } + + return ( + +
+
+

Chargement des prochains départs...

+
+ + }> + +
+ ); +} diff --git a/src/components/HeaderMenu.tsx b/src/components/HeaderMenu.tsx new file mode 100644 index 0000000..c7f7e6c --- /dev/null +++ b/src/components/HeaderMenu.tsx @@ -0,0 +1,394 @@ +"use client"; + +import React, { useState, useEffect, useRef } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { supabase } from '@/lib/supabase'; +import { getRegionTheme } from '@/lib/regionTheme'; + +export interface MenuItem { + id?: string; + label: string; + url?: string; + type?: string; + page_id?: string; + sous_elements?: MenuItem[]; + sous_sous_elements?: MenuItem[]; +} + +interface HeaderMenuProps { + menuStructure?: MenuItem[] | null; + regionSlug: string; + activeTab?: string; + onTabSelect?: (label: string) => void; + mobileMenuOpen?: boolean; + onMobileMenuToggle?: (open: boolean) => void; +} + +const DEFAULT_MENU: MenuItem[] = [ + { + id: 'def-1', + label: 'Se déplacer', + url: '/se-deplacer', + sous_elements: [ + { + id: 'def-1-1', + label: 'Rechercher un horaire (train, car...)', + url: '/horaires' + }, + { + id: 'def-1-2', + label: 'Prochains départs', + url: '/se-deplacer/prochains-departs' + }, + { + id: 'def-1-3', + label: 'Gares', + url: '/#gares' + } + ], + sous_sous_elements: [] + }, + { + id: 'def-2', + label: 'Abonnements', + url: '/abonnements', + sous_elements: [ + { + id: 'def-2-1', + label: 'Fiches horaires', + url: '/#fiches-horaires' + }, + { + id: 'def-2-2', + label: 'Info trafic', + url: '/#info-trafic', + sous_elements: [ + { id: 'def-2-2-1', label: 'Travaux toutes lignes', url: '/#info-trafic' }, + { id: 'def-2-2-2', label: 'Travaux ligne Paris - Laroche - Dijon - Lyon', url: '/#info-trafic' }, + { id: 'def-2-2-3', label: 'Adaptation axe Lyon - Paray-le-Monial - Nevers', url: '/#info-trafic' } + ] + } + ] + }, + { + id: 'def-3', + label: 'Tarifs et cartes de réduction', + url: '/tarifs-cartes', + sous_elements: [ + { + id: 'def-3-1', + label: 'Cartes du réseau', + url: '/#cartes-reseau' + }, + { + id: 'def-3-2', + label: 'Mon Info Trafic : alertes perturbations par sms/mail', + url: '/#alertes-sms' + } + ] + }, + { + id: 'def-4', + label: 'Services et contacts', + url: '/services-contacts', + sous_elements: [] + }, + { + id: 'def-5', + label: 'Découvrir la région', + url: '/decouvrir-la-region', + sous_elements: [] + } +]; + +export default function HeaderMenu({ + menuStructure, + regionSlug, + activeTab = "Se déplacer", + onTabSelect, + mobileMenuOpen: controlledMobileMenuOpen, + onMobileMenuToggle, +}: HeaderMenuProps) { + const pathname = usePathname(); + const theme = getRegionTheme(regionSlug); + const [hoveredIndex, setHoveredIndex] = useState(null); + const [internalMobileMenuOpen, setInternalMobileMenuOpen] = useState(false); + const [expandedMobileIndices, setExpandedMobileIndices] = useState>({}); + const timeoutRef = useRef(null); + + const [items, setItems] = useState(menuStructure && menuStructure.length > 0 ? menuStructure : DEFAULT_MENU); + + useEffect(() => { + if (menuStructure && menuStructure.length > 0) { + setItems(menuStructure); + return; + } + + const fetchRegionalMenu = async () => { + try { + const cleanSlug = regionSlug || 'bourgogne-franche-comte'; + const { data: rData } = await supabase + .from('regions') + .select('id, slug') + .or(`slug.eq.${cleanSlug},code.eq.${cleanSlug}`); + + const rIds = rData ? rData.map((r: any) => r.id) : []; + + let query = supabase.from('menus_regionaux').select('structure').eq('actif', true); + if (rIds.length > 0) { + query = query.in('region_id', rIds); + } + + const { data: mData } = await query.limit(1); + if (mData && mData.length > 0 && mData[0].structure && Array.isArray(mData[0].structure)) { + setItems(mData[0].structure); + } else { + setItems(DEFAULT_MENU); + } + } catch (err) { + console.error('Error fetching menus_regionaux:', err); + } + }; + + fetchRegionalMenu(); + }, [menuStructure, regionSlug]); + + const isMobileMenuOpen = controlledMobileMenuOpen !== undefined ? controlledMobileMenuOpen : internalMobileMenuOpen; + + const setMobileMenuOpen = (open: boolean) => { + if (onMobileMenuToggle) { + onMobileMenuToggle(open); + } else { + setInternalMobileMenuOpen(open); + } + }; + + const handleMouseEnter = (index: number) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setHoveredIndex(index); + }; + + const handleMouseLeave = () => { + timeoutRef.current = setTimeout(() => { + setHoveredIndex(null); + }, 150); + }; + + const resolveUrl = (rawUrl?: string) => { + if (!rawUrl) return `/${regionSlug}`; + if (rawUrl.startsWith('#')) return `/${regionSlug}${rawUrl}`; + if (rawUrl.startsWith('/')) { + if (rawUrl === '/se-deplacer/horaires' || rawUrl === '/horaires') { + return `/${regionSlug}/horaires`; + } + return `/${regionSlug}${rawUrl}`; + } + return rawUrl; + }; + + const toggleMobileIndex = (idx: number) => { + setExpandedMobileIndices(prev => ({ ...prev, [idx]: !prev[idx] })); + }; + + const activeItem = hoveredIndex !== null ? items[hoveredIndex] : null; + + const getSubItems = (item: MenuItem): MenuItem[] => { + const list: MenuItem[] = []; + if (item.sous_elements && Array.isArray(item.sous_elements)) { + list.push(...item.sous_elements); + } + if (item.sous_sous_elements && Array.isArray(item.sous_sous_elements)) { + list.push(...item.sous_sous_elements); + } + return list; + }; + + const activeSubItems = activeItem ? getSubItems(activeItem) : []; + + return ( +
+ {/* DESKTOP NAVIGATION BAR (IMAGE 1 & 2) */} + + + {/* MOBILE BAR & ACCORDION DRAWER */} +
+ Menu Régional + +
+ + {isMobileMenuOpen && ( +
+ {items.map((item, idx) => { + const subItems = getSubItems(item); + const isExpanded = !!expandedMobileIndices[idx]; + + return ( +
+
+ setMobileMenuOpen(false)} + className="text-[14px] font-semibold text-gray-800 hover:text-[#006837] py-1" + > + {item.label} + + + {subItems.length > 0 && ( + + )} +
+ + {isExpanded && subItems.length > 0 && ( +
+ {subItems.map((sub, sIdx) => { + const nestedList = getSubItems(sub); + return ( +
+ setMobileMenuOpen(false)} + className="text-[13px] font-medium text-gray-700 hover:text-[#006837] block py-0.5" + > + {sub.label} + + {nestedList.length > 0 && ( +
+ {nestedList.map((nested, nIdx) => ( + setMobileMenuOpen(false)} + className="text-[12px] text-gray-500 hover:text-gray-800 block py-0.5" + > + {nested.label} + + ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/HorairesPageClient.tsx b/src/components/HorairesPageClient.tsx new file mode 100644 index 0000000..37f49b3 --- /dev/null +++ b/src/components/HorairesPageClient.tsx @@ -0,0 +1,172 @@ +"use client"; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import SiteHeader from '@/components/SiteHeader'; +import TimetableSearchForm from '@/components/TimetableSearchForm'; +import ClientWcsProvider from '@/components/ClientWcsProvider'; + +interface HorairesPageClientProps { + regionSlug: string; + regionData: { + nom_site: string; + logo_header_url: string; + description_site: string; + }; + menuStructure?: any; +} + +const REGION_CONFIGS: Record = { + 'bourgogne-franche-comte': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CBFC/2020-04/1920x680-BFC-Champs-Balles-de-Paille.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/bfc/headerLogo.svg', + name: 'Bourgogne-Franche-Comté' + }, + 'occitanie': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/COC/2021-04/1920x680_Occitanie_0.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/occitanie/headerLogo.svg', + name: 'Occitanie' + }, + 'provence-alpes-cote-azur': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CZOU/2021-05/1920x680_ZOU.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/paca/headerLogo.svg', + name: 'Provence-Alpes-Côte d\'Azur' + }, + 'sud-provence-alpes-cote-d-azur': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CZOU/2021-05/1920x680_ZOU.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/paca/headerLogo.svg', + name: 'Provence-Alpes-Côte d\'Azur' + }, + 'bretagne': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CBR/2020-04/1920x680_Bretagne.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/bretagne/headerLogo.svg', + name: 'Bretagne' + }, + 'grand-est': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CGE/2020-04/1920x680_Grand_Est.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/ge/headerLogo.svg', + name: 'Grand-Est' + }, + 'normandie': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CNO/2020-04/1920x680_Normandie.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/normandie/headerLogo.svg', + name: 'Normandie' + }, + 'hauts-de-france': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CHDF/2020-04/1920x680_Hauts_de_France.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/hdf/headerLogo.svg', + name: 'Hauts-de-France' + }, + 'pays-de-la-loire': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CPDL/2020-04/1920x680_Pays_de_la_Loire.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/aleop/headerLogo.svg', + name: 'Pays de la Loire' + }, + 'nouvelle-aquitaine': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CNA/2020-04/1920x680_Nouvelle_Aquitaine.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/na/headerLogo.svg', + name: 'Nouvelle-Aquitaine' + }, + 'centre-val-de-loire': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CCEN/2020-04/1920x680_Centre.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/cen/headerLogo.svg', + name: 'Centre-Val de Loire' + }, + 'auvergne-rhone-alpes': { + heroImage: 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CAURA/2020-04/1920x680_AURA.jpg', + logoUrl: 'https://www.ter.sncf.com/assets/medias/aura/headerLogo.svg', + name: 'Auvergne-Rhône-Alpes' + } +}; + +const DEFAULT_HERO = 'https://mmt.vsct.fr/sites/default/files/styles/swt_hero/public/swt/CBFC/2020-04/1920x680-BFC-Champs-Balles-de-Paille.jpg'; + +export default function HorairesPageClient({ + regionSlug, + regionData, + menuStructure, +}: HorairesPageClientProps) { + const cfg = REGION_CONFIGS[regionSlug] || { + heroImage: DEFAULT_HERO, + logoUrl: regionData.logo_header_url || '', + name: regionSlug.replace(/-/g, ' ').toUpperCase() + }; + + const logoUrl = regionData.logo_header_url || cfg.logoUrl; + const heroImage = cfg.heroImage; + + const [searchFeedback, setSearchFeedback] = useState(null); + + const handleSearchResults = (params: any) => { + setSearchFeedback(`Recherche d'horaires lancée de "${params.departure || 'Gare de départ'}" à "${params.arrival || 'Gare d\'arrivée'}" pour le ${params.outwardDate} à ${params.outwardTime}.`); + }; + + return ( + +
+ + {/* ================================================================== */} + {/* 1. STICKY HEADER COMPONENT WITH REGIONAL LOGO AND MENU */} + {/* ================================================================== */} + + + {/* ================================================================== */} + {/* 3. BREADCRUMB BAR BELOW MENU (IMAGE 1) */} + {/* ================================================================== */} +
+
+ + home + + > + Rechercher un horaire +
+
+ + {/* ================================================================== */} + {/* 4. HERO SECTION WITH SEARCH CARD ON THE LEFT (IMAGE 1) */} + {/* ================================================================== */} +
+
+ {searchFeedback && ( +
+ {searchFeedback} + +
+ )} + + {/* Timetable Search Form card pinned to LEFT side (Image 1) */} +
+ +
+
+
+ + {/* ================================================================== */} + {/* 5. FOOTER */} + {/* ================================================================== */} +
+
+ Accueil {cfg.name} + • + Rechercher un horaire + • + Info Trafic + • + Actualités +
+

© FERROVIA - Tous droits réservés.

+
+ +
+
+ ); +} diff --git a/src/components/OffersPageClient.tsx b/src/components/OffersPageClient.tsx new file mode 100644 index 0000000..782c856 --- /dev/null +++ b/src/components/OffersPageClient.tsx @@ -0,0 +1,1140 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { supabase } from '@/lib/supabase'; +import SiteHeader from '@/components/SiteHeader'; + +interface OffersPageClientProps { + regionSlug?: string; + menuStructure?: any; +} + +export interface TrainStop { + time: string; + stationName: string; +} + +export interface PerturbationInfo { + id?: string; + type?: string; + cause?: string; + causeDetails?: string; + delayMinutes?: number; + notes?: string; +} + +export interface TrainOffer { + id: string; + departureTime: string; + arrivalTime: string; + duration: string; + departureStation: string; + arrivalStation: string; + trainType: string; + trainNumber: string; + finalDestination: string; + operator: string; + trainModel: string; + capacityText: string; + onboardServices: string; + priceClass: string; + price: string; + stops: TrainStop[]; + serviceAnnuelName?: string; + perturbation?: PerturbationInfo | null; + trainModelImageUrl?: string; +} + +const DEFAULT_STOPS_FULL: TrainStop[] = [ + { time: '06:44', stationName: 'Dijon' }, + { time: '06:56', stationName: 'Ouges' }, + { time: '07:00', stationName: 'Saulon' }, + { time: '07:04', stationName: 'Longecourt-en-Plaine' }, + { time: '07:07', stationName: 'Aiserey' }, + { time: '07:12', stationName: 'Brazey-en-Plaine' }, + { time: '07:16', stationName: 'Saint-Jean-de-Losne' }, + { time: '07:19', stationName: 'Chaugey' }, + { time: '07:23', stationName: 'Pagny' }, + { time: '07:28', stationName: 'Seurre' }, + { time: '08:30', stationName: 'Bourg-en-Bresse' }, +]; + +const DEFAULT_OFFERS: TrainOffer[] = [ + { + id: '1', + departureTime: '06:44', + arrivalTime: '07:23', + duration: '39 min', + departureStation: 'Dijon', + arrivalStation: 'Pagny', + trainType: 'TER', + trainNumber: '891801', + finalDestination: 'Bourg-en-Bresse', + operator: 'SNCF Voyageurs - 1187', + trainModel: 'Un train "AGC"', + capacityText: '4 voitures - 220 places', + onboardServices: '🚲', + priceClass: '2nde classe', + price: '6,50 €', + stops: DEFAULT_STOPS_FULL.slice(0, 9), + }, + { + id: '2', + departureTime: '07:30', + arrivalTime: '08:08', + duration: '38 min', + departureStation: 'Dijon', + arrivalStation: 'Pagny', + trainType: 'TER', + trainNumber: '891803', + finalDestination: 'Louhans', + operator: 'SNCF Voyageurs - 1187', + trainModel: 'Un train "AGC"', + capacityText: '4 voitures - 220 places', + onboardServices: '🚲', + priceClass: '2nde classe', + price: '6,50 €', + stops: [ + { time: '07:30', stationName: 'Dijon' }, + { time: '07:42', stationName: 'Ouges' }, + { time: '07:46', stationName: 'Saulon' }, + { time: '07:50', stationName: 'Longecourt-en-Plaine' }, + { time: '07:53', stationName: 'Aiserey' }, + { time: '07:58', stationName: 'Brazey-en-Plaine' }, + { time: '08:02', stationName: 'Saint-Jean-de-Losne' }, + { time: '08:05', stationName: 'Chaugey' }, + { time: '08:08', stationName: 'Pagny' }, + { time: '08:13', stationName: 'Seurre' }, + ], + }, + { + id: '3', + departureTime: '08:44', + arrivalTime: '09:23', + duration: '39 min', + departureStation: 'Dijon', + arrivalStation: 'Pagny', + trainType: 'TER', + trainNumber: '891805', + finalDestination: 'Bourg-en-Bresse', + operator: 'SNCF Voyageurs - 1187', + trainModel: 'Un train "AGC"', + capacityText: '4 voitures - 220 places', + onboardServices: '🚲', + priceClass: '2nde classe', + price: '6,50 €', + stops: DEFAULT_STOPS_FULL.slice(0, 9), + }, + { + id: '4', + departureTime: '12:15', + arrivalTime: '13:48', + duration: '1h33', + departureStation: 'Dijon', + arrivalStation: 'Pagny', + trainType: 'TER', + trainNumber: '418107', + finalDestination: 'Louhans', + operator: 'SNCF Voyageurs - 1187', + trainModel: 'Un train "AGC"', + capacityText: '4 voitures - 220 places', + onboardServices: '🚲', + priceClass: '2nde classe', + price: '6,50 €', + stops: [ + { time: '12:15', stationName: 'Dijon' }, + { time: '13:00', stationName: 'Saint-Jean-de-Losne' }, + { time: '13:48', stationName: 'Pagny' }, + ], + }, + { + id: '5', + departureTime: '16:44', + arrivalTime: '17:23', + duration: '39 min', + departureStation: 'Dijon', + arrivalStation: 'Pagny', + trainType: 'TER', + trainNumber: '891811', + finalDestination: 'Bourg-en-Bresse', + operator: 'SNCF Voyageurs - 1187', + trainModel: 'Un train "AGC"', + capacityText: '4 voitures - 220 places', + onboardServices: '🚲', + priceClass: 'À partir de', + price: '6,00 €', + stops: DEFAULT_STOPS_FULL.slice(0, 9), + }, +]; + +export default function OffersPageClient({ + regionSlug = 'bourgogne-franche-comte', + menuStructure, +}: OffersPageClientProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Search parameters from URL + const departure = searchParams.get('departure') || 'Dijon'; + const arrival = searchParams.get('arrival') || 'Pagny'; + const outwardDate = searchParams.get('outwardDate') || 'Lundi 21 septembre'; + const outwardTime = searchParams.get('outwardTime') || '00:00'; + const passengerCount = searchParams.get('passengers') || '1'; + const passengerAge = searchParams.get('passengerAge') || '30'; + const discountCard = searchParams.get('discountCard') || 'sans carte'; + + const [offers, setOffers] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedDetailsId, setExpandedDetailsId] = useState(null); + const [selectedModalOffer, setSelectedModalOffer] = useState(null); + const [currentServiceAnnuel, setCurrentServiceAnnuel] = useState(null); + + // Robust station matching helper (exact or prefix match to prevent false partial matches) + const matchStation = (query: string, stationNom: string): boolean => { + if (!query || !stationNom) return false; + const clean = (s: string) => (s || '') + .toLowerCase() + .normalize("NFD").replace(/[\u0300-\u036f]/g, "") + .replace(/\s*\(toutes gares\)/g, '') + .replace(/gare de /g, '') + .replace(/gare d'/g, '') + .trim(); + const q = clean(query); + const st = clean(stationNom); + if (!q || !st) return false; + return q === st || st.startsWith(q) || q.startsWith(st); + }; + + // Helper to extract exact segment stops & times for user departure -> arrival segment + const resolveTrainSegment = (item: any, depQuery: string, arrQuery: string, gMap: Record) => { + let stops: TrainStop[] = []; + + if (item.gares_desservies && Array.isArray(item.gares_desservies) && item.gares_desservies.length > 0) { + stops = item.gares_desservies.map((g: any) => ({ + time: (g.heure_depart || g.heure_arrivee || '00:00').slice(0, 5), + stationName: g.gare_nom || (g.gare_id ? gMap[g.gare_id] : ''), + })); + } else { + stops = [ + { time: (item.heure_depart || '08:00').slice(0, 5), stationName: item.gare_depart_nom || (item.gare_depart_id ? gMap[item.gare_depart_id] : '') || depQuery }, + { time: (item.heure_arrivee || '08:45').slice(0, 5), stationName: item.gare_arrivee_nom || (item.gare_arrivee_id ? gMap[item.gare_arrivee_id] : '') || arrQuery }, + ]; + } + + const depIdx = stops.findIndex(s => matchStation(depQuery, s.stationName)); + const arrIdx = stops.findIndex(s => matchStation(arrQuery, s.stationName)); + + // STRICT DIRECTION FILTERING: Train MUST travel in direction depIdx < arrIdx! + if (depIdx === -1 || arrIdx === -1 || depIdx >= arrIdx) { + return null; + } + + const segStops = stops.slice(depIdx, arrIdx + 1); + const segDepTime = stops[depIdx].time; + const segArrTime = stops[arrIdx].time; + const segDepName = stops[depIdx].stationName; + const segArrName = stops[arrIdx].stationName; + + // Calculate segment duration + const [h1, m1] = segDepTime.split(':').map(Number); + const [h2, m2] = segArrTime.split(':').map(Number); + let totalMins = (h2 * 60 + m2) - (h1 * 60 + m1); + if (totalMins < 0) totalMins += 1440; + const durHours = Math.floor(totalMins / 60); + const durMins = totalMins % 60; + const durStr = durHours > 0 ? `${durHours}h${durMins < 10 ? '0' : ''}${durMins}` : `${durMins} min`; + + const finalDest = stops[stops.length - 1]?.stationName || item.gare_arrivee_nom || arrQuery; + + return { + depTime: segDepTime, + arrTime: segArrTime, + duration: durStr, + depName: segDepName, + arrName: segArrName, + finalDestination: finalDest, + segmentStops: segStops, + }; + }; + + // Helper function to convert French text dates or slash dates to YYYY-MM-DD + const formatToIsoDate = (dateStr: string, defaultYear = 2026): string => { + if (!dateStr) return `${defaultYear}-09-19`; + const trimmed = dateStr.trim(); + + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + return trimmed; + } + + const slashMatch = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/); + if (slashMatch) { + return `${slashMatch[3]}-${slashMatch[2].padStart(2, '0')}-${slashMatch[1].padStart(2, '0')}`; + } + + const monthMap: Record = { + janv: '01', janvier: '01', + fevr: '02', févr: '02', fevrier: '02', février: '02', + mars: '03', + avr: '04', avril: '04', + mai: '05', + juin: '06', + juil: '07', juillet: '07', + aout: '08', août: '08', + sept: '09', septembre: '09', + oct: '10', octobre: '10', + nov: '11', novembre: '11', + dec: '12', déc: '12', decembre: '12', décembre: '12', + }; + + const lower = trimmed.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + const dayMatch = lower.match(/\b(\d{1,2})\b/); + const dayNum = dayMatch ? dayMatch[1].padStart(2, '0') : '19'; + const yearMatch = lower.match(/\b(20\d{2})\b/); + const year = yearMatch ? yearMatch[1] : defaultYear.toString(); + + let monthStr = '09'; + for (const [key, val] of Object.entries(monthMap)) { + const keyClean = key.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + if (lower.includes(keyClean)) { + monthStr = val; + break; + } + } + + return `${year}-${monthStr}-${dayNum}`; + }; + + // Fetch train offers from Supabase BDD with strict segment filtering & sorting by departure time + useEffect(() => { + const fetchTrainOffers = async () => { + setLoading(true); + try { + // 1. Fetch gares mapping + const { data: garesData } = await supabase.from('gares').select('id, nom'); + const gMap: Record = {}; + if (garesData) { + garesData.forEach((g: any) => { gMap[g.id] = g.nom; }); + } + + // Target ISO date string (YYYY-MM-DD) from user selected date + const targetIsoDate = formatToIsoDate(outwardDate); + + // 2. Fetch services_annuels to determine active SA for targetIsoDate + const { data: saData } = await supabase + .from('services_annuels') + .select('*') + .eq('actif', true); + + const matchingSA = saData?.find((sa: any) => { + const dStart = (sa.date_debut || '').slice(0, 10); + const dEnd = (sa.date_fin || '').slice(0, 10); + return dStart <= targetIsoDate && targetIsoDate <= dEnd; + }); + + if (matchingSA) { + setCurrentServiceAnnuel(`${matchingSA.nom} (${matchingSA.code || 'SA'})`); + } else { + setCurrentServiceAnnuel(null); + } + + // 3. Fetch perturbations from Supabase + const { data: pertData } = await supabase + .from('perturbations') + .select('*') + .eq('actif', true) + .is('deleted_at', null); + + // 4. Query horaires from Supabase + const { data: hData } = await supabase + .from('horaires') + .select('*') + .eq('actif', true) + .is('deleted_at', null); + + // Determine day of week column from outwardDate + const dayLower = (outwardDate || '').toLowerCase(); + let targetCol = 'circule_lundi'; + if (dayLower.includes('sam')) targetCol = 'circule_samedi'; + else if (dayLower.includes('dim')) targetCol = 'circule_dimanche'; + else if (dayLower.includes('lun')) targetCol = 'circule_lundi'; + else if (dayLower.includes('mar')) targetCol = 'circule_mardi'; + else if (dayLower.includes('mer')) targetCol = 'circule_mercredi'; + else if (dayLower.includes('jeu')) targetCol = 'circule_jeudi'; + else if (dayLower.includes('ven')) targetCol = 'circule_vendredi'; + + // Check circulation against jours_personnalises first, then fallback to day column + const filterDay = (item: any): boolean => { + if (item.jours_personnalises && Array.isArray(item.jours_personnalises) && item.jours_personnalises.length > 0) { + const dateEntry = item.jours_personnalises.find((jp: any) => { + const d = (jp.date || jp.jour || '').slice(0, 10); + return d === targetIsoDate; + }); + return dateEntry?.circule === true; + } + + if (item[targetCol] !== undefined && item[targetCol] !== null) { + return Boolean(item[targetCol]); + } + return true; + }; + + const userTimeHHMM = (outwardTime && outwardTime !== '--:--') ? outwardTime.slice(0, 5) : '00:00'; + + if (hData && hData.length > 0) { + const mappedList: TrainOffer[] = []; + const seenKeys = new Set(); + + hData.forEach((item: any, idx: number) => { + // Service Annuel Filter: Match service_annuel_id if a SA covers targetIsoDate + if (matchingSA && item.service_annuel_id && item.service_annuel_id !== matchingSA.id) { + return; + } + + if (!filterDay(item)) return; + + const seg = resolveTrainSegment(item, departure, arrival, gMap); + if (!seg) return; + + // Check if segment departure time is >= user's chosen time + if (seg.depTime < userTimeHHMM) return; + + // Deduplicate same train departure time & number + const uniqueKey = `${seg.depTime}_${seg.depName}_${seg.arrName}_${item.numero_train || idx}`; + if (seenKeys.has(uniqueKey)) return; + seenKeys.add(uniqueKey); + + // Match active perturbation for this train + const matchingPert = pertData?.find((p: any) => { + const matchTrain = p.horaire_id === item.id || p.horaire_id === item.numero_train; + if (!matchTrain) return false; + + const pStart = (p.date_debut || '').slice(0, 10); + const pEnd = (p.date_fin || '').slice(0, 10); + + if (pStart && pEnd && pStart <= targetIsoDate && targetIsoDate <= pEnd) { + return true; + } + if (p.jours_perturbation && Array.isArray(p.jours_perturbation)) { + const jpMatch = p.jours_perturbation.some((jp: any) => { + const d = (jp.date || jp.dateDebut || '').slice(0, 10); + return d === targetIsoDate; + }); + if (jpMatch) return true; + } + return !pStart && !pEnd && (!p.jours_perturbation || p.jours_perturbation.length === 0); + }); + + const pertInfo: PerturbationInfo | null = matchingPert ? { + id: matchingPert.id?.toString(), + type: matchingPert.type_perturbation || 'Perturbation', + cause: matchingPert.cause || 'Incident de circulation', + causeDetails: matchingPert.cause_details || matchingPert.notes || '', + delayMinutes: matchingPert.duree_retard_minutes || 0, + notes: matchingPert.notes || '', + } : null; + + // Determine price based on segment duration or distance + const isShortRoute = seg.arrName.toLowerCase().includes('pagny') || seg.arrName.toLowerCase().includes('brazey') || seg.depName.toLowerCase().includes('brazey'); + const priceVal = isShortRoute ? '6,50 €' : '13,00 €'; + + // Extract matériel roulant image from composition_train BDD + const composition = Array.isArray(item.composition_train) ? item.composition_train : []; + const firstMat = composition.length > 0 ? composition[0] : null; + const matImageUrl = firstMat?.materiel_image_url || 'https://supabase-ferrovia.mrpatator.fr/storage/v1/object/public/materiel-roulant/images/1789909846752_regiolis_firsttosecond_bluegreenmobigo_4c__1_.png'; + const matNameText = firstMat ? `Un train "${firstMat.materiel_nom || 'Régiolis'}" ${firstMat.materiel_nom_technique ? `(${firstMat.materiel_nom_technique})` : ''}` : (item.modele_train || 'Un train "AGC"'); + + mappedList.push({ + id: item.id?.toString() || idx.toString(), + departureTime: seg.depTime, + arrivalTime: seg.arrTime, + duration: seg.duration, + departureStation: seg.depName, + arrivalStation: seg.arrName, + trainType: item.type_train || 'TER', + trainNumber: item.numero_train || `891${idx + 801}`, + finalDestination: seg.finalDestination, + operator: item.operateur || 'SNCF Voyageurs - 1187', + trainModel: matNameText, + capacityText: item.capacite_texte || '4 voitures - 220 places', + onboardServices: '🚲', + priceClass: idx === hData.length - 1 ? 'À partir de' : '2nd classe', + price: priceVal, + stops: seg.segmentStops, + serviceAnnuelName: matchingSA ? `${matchingSA.nom} (${matchingSA.code || ''})` : undefined, + perturbation: pertInfo, + trainModelImageUrl: matImageUrl, + }); + }); + + if (mappedList.length > 0) { + // SORT STRICTLY BY DEPARTURE TIME ASCENDING + mappedList.sort((a, b) => a.departureTime.localeCompare(b.departureTime)); + setOffers(mappedList); + setLoading(false); + return; + } + } + + // Fallback matching default sample offers if DB yields no rows for custom gares + const filteredDefault = DEFAULT_OFFERS.map(o => { + const isDijonSeurre = arrival.toLowerCase().includes('seurre'); + let arrSt = arrival; + let arrTm = o.arrivalTime; + let durSt = o.duration; + let priceSt = o.price; + let stopsList = o.stops; + + if (isDijonSeurre) { + arrSt = 'Seurre'; + arrTm = o.departureTime === '06:44' ? '07:28' : o.departureTime === '07:30' ? '08:13' : '09:28'; + durSt = '44 min'; + priceSt = '13,00 €'; + stopsList = DEFAULT_STOPS_FULL; + } else { + stopsList = o.stops.map((s, idx) => idx === o.stops.length - 1 ? { ...s, stationName: arrival } : s); + } + + return { + ...o, + departureStation: departure, + arrivalStation: arrSt, + arrivalTime: arrTm, + duration: durSt, + price: priceSt, + stops: stopsList, + }; + }).filter(o => o.departureTime >= userTimeHHMM); + + filteredDefault.sort((a, b) => a.departureTime.localeCompare(b.departureTime)); + setOffers(filteredDefault); + + } catch (err) { + console.error('Error fetching horaires from Supabase:', err); + const filtered = DEFAULT_OFFERS.filter(o => o.departureTime >= (outwardTime.slice(0, 5) || '00:00')); + filtered.sort((a, b) => a.departureTime.localeCompare(b.departureTime)); + setOffers(filtered); + } finally { + setLoading(false); + } + }; + + fetchTrainOffers(); + }, [departure, arrival, outwardDate, outwardTime]); + + const handleModifySearch = () => { + const searchUrl = regionSlug ? `/${regionSlug}/horaires` : '/horaires'; + router.push(searchUrl); + }; + + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+
+ {/* ─── STICKY HEADER COMPONENT ─── */} + + + {/* ─── MAIN CONTENT AREA (STRICTLY CONFORME IMG 1 & 2) ─── */} +
+ + {/* Mobile Summary Banner (Img 2) */} +
+
+ +
+
+ {departure} → {arrival} +
+
+ {outwardDate.toLowerCase()} , {passengerCount} voyageur{Number(passengerCount) > 1 ? 's' : ''} +
+
+
+
+ + {/* Page Title & Subtitle Link */} +
+

+ Sélectionner votre billet +

+ +
+ + {/* Grid Layout: Left Results Column + Right Recap Sidebar */} +
+ + {/* ─── LEFT COLUMN: TRAIN OFFERS LIST (IMG 1 & 2) ─── */} +
+ + {/* Filter / Date Row Header */} +
+
+ event + + {outwardDate.includes('sept') || outwardDate.includes('oct') || outwardDate.includes('nov') ? outwardDate : `Date : ${outwardDate}`} + + {currentServiceAnnuel && ( + + calendar_today + Service Annuel : {currentServiceAnnuel} + + )} +
+ +
+ + {/* Train Result Cards */} +
+ {loading ? ( +
+ refresh +

Recherche des horaires en cours dans la base Supabase...

+
+ ) : offers.length > 0 ? ( + offers.map((offer) => { + const isExpanded = expandedDetailsId === offer.id; + return ( +
+ {/* PERTURBATION WARNING BANNER */} + {offer.perturbation && ( +
+
+ warning + Perturbation : + {offer.perturbation.cause || offer.perturbation.type || 'Incident de circulation'} +
+ {offer.perturbation.delayMinutes ? ( + + Retard : +{offer.perturbation.delayMinutes} min + + ) : null} +
+ )} + + {/* MAIN CARD ROW (CONFORME IMG 1 DESKTOP & IMG 2 MOBILE) */} +
+ + {/* LEFT/CENTER CONTENT BLOCK */} +
+ + {/* TIMELINE ROW */} +
+ + {/* Departure Block */} +
+ + {offer.departureTime} + + + {offer.departureStation} + +
+ + {/* Horizontal duration timeline matching Img 1 & 2 */} +
+ +
+ + {offer.duration} + +
+ +
+ + {/* Arrival Block */} +
+ + {offer.arrivalTime} + + + {offer.arrivalStation} + +
+ +
+ + {/* METADATA ROW: Train Icon/Number + Details Button (Img 1 desktop & Img 2 mobile) */} +
+ + {/* Details Pill Button (Bottom left on desktop & mobile) */} +
+ +
+ + {/* Train Icon & Number (Under Pagny on Desktop, Under Dijon on Mobile) */} +
+ directions_railway + {offer.trainType} {offer.trainNumber} +
+ +
+ +
+ + {/* PRICE BUTTON BADGE (CONFORME IMG 1 & 2) */} +
+ +
+ +
+ + {/* ─── EXPANDED ACCORDION DETAILS (STRICTLY CONFORME IMG 1) ─── */} + {isExpanded && ( +
+ + {/* LEFT COLUMN: TIMELINE & TRAIN DETAILS (IMG 1) */} +
+ + {/* Departure node */} +
+ + {offer.departureTime} + + directions_railway + {offer.departureStation} +
+ + {/* Vertical line & details block */} +
+ {/* Left vertical line with duration */} +
+
+ + {offer.duration} + +
+
+ + {/* Train info matching Image 1 */} +
+ + {/* Clickable Underlined Train Link -> Opens Modal (Image 2) */} + + +

+ Destination: {offer.finalDestination} +

+

+ Opéré par {offer.operator} +

+ + {/* Perturbation Accordion Alert */} + {offer.perturbation && ( +
+
+ warning + Information Perturbation +
+

Cause : {offer.perturbation.cause}

+ {offer.perturbation.causeDetails &&

{offer.perturbation.causeDetails}

} + {offer.perturbation.delayMinutes ?

Retard estimé : +{offer.perturbation.delayMinutes} min

: null} +
+ )} + + {/* Matériel Roulant Image asset + Green Right Arrow Button */} +
+
+ {offer.trainModel +
+ +
+ +

+ {offer.trainModel} +

+

+ {offer.capacityText} +

+ + {/* Bicycle icon */} +
+ À bord : + pedal_bike +
+
+
+ + {/* Arrival node */} +
+ + {offer.arrivalTime} + + place + {offer.arrivalStation} +
+ +
+ + {/* RIGHT COLUMN: FARE / PASSENGER CARD INSIDE ACCORDION (IMAGE 1) */} +
+
+ +
+
+
+ + person + +
+

+ Tarif jeune TRAIN Mobigo -26 +

+

+ Voyageur 1 +

+
+
+ + {offer.price} + +
+ +
+

Adulte - Remboursable

+ +
+
+ + {/* Green price button at bottom right of card */} +
+ +
+ +
+
+ +
+ )} + +
+ ); + }) + ) : ( +
+ train +

+ Aucun horaire trouvé pour ce trajet +

+

+ Aucun train ne circule entre {departure} et {arrival} le {outwardDate} après {outwardTime} dans la base de données. +

+ +
+ )} +
+ + {/* Horaires suivants link */} +
+ +
+ + {/* Haut de page Pill Button (CONFORME IMG 1 & 2) */} +
+ +
+ +
+ + {/* ─── RIGHT SIDEBAR: SEARCH SUMMARY CARD (STRICTLY CONFORME IMG 1 & 2) ─── */} +
+
+ +
+ Aller le {outwardDate.toLowerCase().includes('lun') ? outwardDate : 'lun. 21 sept.'} à {outwardTime} +
+ +
+ {departure} → {arrival} +
+ +
+ {passengerCount} voyageur{parseInt(passengerCount) > 1 ? 's' : ''}, {passengerAge} ans, {discountCard} +
+ + {/* Modifier ma recherche button */} +
+ +
+ +
+
+ +
+ +
+
+ + {/* ─── MODALE ETAPES DU TRAIN (STRICTLY CONFORME IMG 2) ─── */} + {selectedModalOffer && ( +
setSelectedModalOffer(null)} + > +
e.stopPropagation()} + > + {/* Top right close button */} + + + {/* Header Title: Gare Départ -> Gare Arrivée */} +

+ {selectedModalOffer.departureStation} → {selectedModalOffer.arrivalStation} +

+ + {/* Table Headers */} +
+ Heure de passage + Etape +
+ + {/* Timeline List of Stops (IMAGE 2) */} +
+ {selectedModalOffer.stops && selectedModalOffer.stops.length > 0 ? ( +
+ {/* Green vertical line connecting all dots */} +
+ + {selectedModalOffer.stops.map((stop, idx) => ( +
+ {/* Time */} + + {stop.time} + + + {/* Green Dot */} +
+ +
+ + {/* Station Name */} + + {stop.stationName} + +
+ ))} +
+ ) : ( +
+ Information d'étapes non disponible. +
+ )} +
+ +
+
+ )} + + {/* ─── FOOTER SECTION ─── */} +
+
+ + {/* Region Selector Bar & Logos Row */} +
+ + {/* Region Dropdown Selector */} +
+ +
+ + {/* Regional & Network Logos */} +
+ Logos SNCF et Mobigo +
+ +
+ +
+ + {/* Social Icons Row */} + + + {/* Legal Links Row */} + + + + +
+
+ +
+ ); +} diff --git a/src/components/ProchainsDepartsClient.tsx b/src/components/ProchainsDepartsClient.tsx new file mode 100644 index 0000000..01da5a7 --- /dev/null +++ b/src/components/ProchainsDepartsClient.tsx @@ -0,0 +1,1865 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; +import { supabase } from '@/lib/supabase'; +import SiteHeader from '@/components/SiteHeader'; +import { getRegionTheme } from '@/lib/regionTheme'; +import { isPerturbationActiveOnDate, getPerturbationBannerStyle } from '@/lib/perturbationUtils'; + +interface ProchainsDepartsClientProps { + regionSlug?: string; + menuStructure?: any; +} + +export interface StationOption { + id: string; + nom: string; + type?: string; +} + +export interface DepartureRow { + id: string; + departTime: string; + revisedTime?: string; + delayText?: string; + isSupprime?: boolean; + destination: string; + modeIcon: 'bus' | 'train'; + modeLabel: string; + trainNumber: string; + trainBadge?: string; + operator?: string; + voie: string; + rawQuai?: string; + dateIso?: string; // e.g. "2026-09-20" or "2026-09-21" + dateLabel?: string; // e.g. "Lundi 21 septembre" + perturbationType?: 'retard' | 'suppression' | 'information' | 'parcours_modifie' | string; + perturbationDetails?: string; + infoBadge?: string; + travauxBanner?: string; + stops: { + departTime: string; + gareNom: string; + voie: string; + isDepart?: boolean; + isArrivee?: boolean; + }[]; +} + +export interface ArrivalRow { + id: string; + arrivalTime: string; + revisedTime?: string; + delayText?: string; + isSupprime?: boolean; + provenance: string; + modeIcon: 'bus' | 'train'; + modeLabel: string; + trainNumber: string; + trainBadge?: string; + operator?: string; + voie: string; + dateIso?: string; + dateLabel?: string; + perturbationType?: 'retard' | 'suppression' | 'information' | 'parcours_modifie' | string; + perturbationDetails?: string; + stops: { + departTime: string; + gareNom: string; + voie: string; + isDepart?: boolean; + isArrivee?: boolean; + }[]; +} + +export interface TravauxItem { + id: string; + titre: string; + description: string; + ligne_nom?: string; +} + +// ─── BRAZEY-EN-PLAINE DEFAULT DATASET (STRICTLY CONFORME IMAGE 1) ─── +const DEFAULT_BRAZEY_DEPARTURES: DepartureRow[] = [ + { + id: 'b1', + departTime: '18:50', + destination: 'Dijon', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '891816', + trainBadge: 'P7', + voie: '2', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-20', + stops: [ + { departTime: '18:50', gareNom: 'Brazey-en-Plaine', voie: '2', isDepart: true }, + { departTime: '18:55', gareNom: 'Aiserey', voie: '2' }, + { departTime: '18:59', gareNom: 'Longecourt-en-Plaine', voie: '-' }, + { departTime: '19:03', gareNom: 'Saulon', voie: '-' }, + { departTime: '19:07', gareNom: 'Ouges', voie: '-' }, + { departTime: '19:16', gareNom: 'Dijon', voie: '-', isArrivee: true }, + ] + }, + { + id: 'b2', + departTime: '19:12', + destination: 'Bourg-en-Bresse', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '891817', + voie: '1', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-20', + stops: [ + { departTime: '19:12', gareNom: 'Brazey-en-Plaine', voie: '1', isDepart: true }, + { departTime: '19:45', gareNom: 'Seurre', voie: '1' }, + { departTime: '20:30', gareNom: 'Bourg-en-Bresse', voie: '2', isArrivee: true }, + ] + }, + { + id: 'b3', + departTime: '19:49', + destination: 'Dijon', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '891818', + voie: '2', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-20', + stops: [ + { departTime: '19:49', gareNom: 'Brazey-en-Plaine', voie: '2', isDepart: true }, + { departTime: '20:15', gareNom: 'Dijon', voie: '1', isArrivee: true }, + ] + }, + { + id: 'b4', + departTime: '20:33', + destination: 'Seurre', + modeIcon: 'bus', + modeLabel: 'Car TER', + trainNumber: '37253', + voie: '-', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-20', + stops: [ + { departTime: '20:33', gareNom: 'Brazey-en-Plaine', voie: '-', isDepart: true }, + { departTime: '21:05', gareNom: 'Seurre', voie: '-', isArrivee: true }, + ] + }, + // Date Transition to Next Day + { + id: 'b5', + departTime: '06:49', + destination: 'Dijon', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '891800', + voie: '-', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-21', + dateLabel: 'Lundi 21 septembre', + stops: [ + { departTime: '06:49', gareNom: 'Brazey-en-Plaine', voie: '-', isDepart: true }, + { departTime: '07:15', gareNom: 'Dijon', voie: '-', isArrivee: true }, + ] + }, + { + id: 'b6', + departTime: '07:12', + destination: 'Bourg-en-Bresse', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '891801', + voie: '-', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-21', + stops: [ + { departTime: '07:12', gareNom: 'Brazey-en-Plaine', voie: '-', isDepart: true }, + { departTime: '08:30', gareNom: 'Bourg-en-Bresse', voie: '-', isArrivee: true }, + ] + }, + { + id: 'b7', + departTime: '07:17', + destination: 'Dijon', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '891802', + voie: '-', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-21', + stops: [ + { departTime: '07:17', gareNom: 'Brazey-en-Plaine', voie: '-', isDepart: true }, + { departTime: '07:45', gareNom: 'Dijon', voie: '-', isArrivee: true }, + ] + }, + { + id: 'b8', + departTime: '07:49', + destination: 'Dijon', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '891804', + voie: '-', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-21', + stops: [ + { departTime: '07:49', gareNom: 'Brazey-en-Plaine', voie: '-', isDepart: true }, + { departTime: '08:15', gareNom: 'Dijon', voie: '-', isArrivee: true }, + ] + }, + { + id: 'b9', + departTime: '07:58', + destination: 'Seurre', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '891803', + voie: '-', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-21', + stops: [ + { departTime: '07:58', gareNom: 'Brazey-en-Plaine', voie: '-', isDepart: true }, + { departTime: '08:25', gareNom: 'Seurre', voie: '-', isArrivee: true }, + ] + }, + { + id: 'b10', + departTime: '08:50', + destination: 'Dijon', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '891806', + voie: '-', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-21', + stops: [ + { departTime: '08:50', gareNom: 'Brazey-en-Plaine', voie: '-', isDepart: true }, + { departTime: '09:16', gareNom: 'Dijon', voie: '-', isArrivee: true }, + ] + }, + { + id: 'b11', + departTime: '09:12', + destination: 'Seurre', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '891805', + voie: '-', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-21', + stops: [ + { departTime: '09:12', gareNom: 'Brazey-en-Plaine', voie: '-', isDepart: true }, + { departTime: '09:40', gareNom: 'Seurre', voie: '-', isArrivee: true }, + ] + }, + { + id: 'b12', + departTime: '13:09', + destination: 'Seurre', + modeIcon: 'bus', + modeLabel: 'Car TER', + trainNumber: '418107', + voie: '-', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-21', + stops: [ + { departTime: '13:09', gareNom: 'Brazey-en-Plaine', voie: '-', isDepart: true }, + { departTime: '13:40', gareNom: 'Seurre', voie: '-', isArrivee: true }, + ] + }, + { + id: 'b13', + departTime: '14:39', + destination: 'Dijon', + modeIcon: 'bus', + modeLabel: 'Car TER', + trainNumber: '418098', + voie: '-', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-21', + stops: [ + { departTime: '14:39', gareNom: 'Brazey-en-Plaine', voie: '-', isDepart: true }, + { departTime: '15:10', gareNom: 'Dijon', voie: '-', isArrivee: true }, + ] + } +]; + +// ─── LILLE FLANDRES DEFAULT DATASET (STRICTLY CONFORME IMAGE 2) ─── +const DEFAULT_LILLE_DEPARTURES: DepartureRow[] = [ + { + id: 'lf1', + departTime: '16:29', + destination: 'Kortrijk', + modeIcon: 'bus', + modeLabel: 'Car TER', + trainNumber: '85632', + voie: '-', + operator: 'SNCF Voyageurs', + dateIso: '2026-09-20', + stops: [ + { departTime: '16:29', gareNom: 'Lille Flandres', voie: '-', isDepart: true }, + { departTime: '17:10', gareNom: 'Kortrijk', voie: '-', isArrivee: true } + ] + }, + { + id: 'lf2', + departTime: '16:35', + destination: 'Valenciennes', + modeIcon: 'train', + modeLabel: 'Train TER HDF', + trainNumber: '', + trainBadge: 'K60', + voie: '10', + perturbationType: 'information', + perturbationDetails: 'Informations', + operator: 'SNCF Voyageurs', + dateIso: '2026-09-20', + stops: [ + { departTime: '16:35', gareNom: 'Lille Flandres', voie: '10', isDepart: true }, + { departTime: '17:15', gareNom: 'Valenciennes', voie: '2', isArrivee: true } + ] + }, + { + id: 'lf3', + departTime: '16:38', + destination: 'Calais Ville', + modeIcon: 'train', + modeLabel: 'Train TER HDF', + trainNumber: '', + trainBadge: 'K71', + voie: '14', + perturbationType: 'information', + perturbationDetails: 'Informations', + operator: 'SNCF Voyageurs', + dateIso: '2026-09-20', + stops: [ + { departTime: '16:38', gareNom: 'Lille Flandres', voie: '14', isDepart: true }, + { departTime: '17:40', gareNom: 'Calais Ville', voie: '1', isArrivee: true } + ] + }, + { + id: 'lf4', + departTime: '16:42', + destination: 'Saint-Pol-sur-Ternoise', + modeIcon: 'train', + modeLabel: 'Train TER HDF', + trainNumber: '', + trainBadge: 'K50', + voie: '2', + perturbationType: 'information', + perturbationDetails: 'Information', + operator: 'SNCF Voyageurs', + dateIso: '2026-09-20', + stops: [ + { departTime: '16:42', gareNom: 'Lille Flandres', voie: '2', isDepart: true }, + { departTime: '17:50', gareNom: 'Saint-Pol-sur-Ternoise', voie: '1', isArrivee: true } + ] + }, + { + id: 'lf5', + departTime: '16:47', + destination: 'Lens', + modeIcon: 'train', + modeLabel: 'Train TER HDF', + trainNumber: '', + trainBadge: 'C41', + voie: '4', + perturbationType: 'information', + perturbationDetails: 'Informations', + operator: 'SNCF Voyageurs', + dateIso: '2026-09-20', + stops: [ + { departTime: '16:47', gareNom: 'Lille Flandres', voie: '4', isDepart: true }, + { departTime: '17:25', gareNom: 'Lens', voie: '2', isArrivee: true } + ] + }, + { + id: 'lf6', + departTime: '16:50', + destination: 'Paris Nord', + modeIcon: 'train', + modeLabel: 'Train TGV INOUI', + trainNumber: '7060', + voie: '-', + operator: 'SNCF Voyageurs', + dateIso: '2026-09-20', + stops: [ + { departTime: '16:50', gareNom: 'Lille Flandres', voie: '-', isDepart: true }, + { departTime: '17:52', gareNom: 'Paris Nord', voie: ' Hall 1', isArrivee: true } + ] + }, + { + id: 'lf7', + departTime: '16:54', + destination: 'Tournai', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '19947', + voie: '11', + perturbationType: 'information', + perturbationDetails: 'Information', + operator: 'SNCF Voyageurs', + dateIso: '2026-09-20', + stops: [ + { departTime: '16:54', gareNom: 'Lille Flandres', voie: '11', isDepart: true }, + { departTime: '17:25', gareNom: 'Tournai', voie: '1', isArrivee: true } + ] + }, + { + id: 'lf8', + departTime: '17:05', + destination: 'Rouen Rive Droite', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '848910', + voie: '-', + perturbationType: 'information', + perturbationDetails: 'Information', + operator: 'SNCF Voyageurs', + dateIso: '2026-09-20', + stops: [ + { departTime: '17:05', gareNom: 'Lille Flandres', voie: '-', isDepart: true }, + { departTime: '19:40', gareNom: 'Rouen Rive Droite', voie: '1', isArrivee: true } + ] + }, + { + id: 'lf9', + departTime: '17:05', + destination: 'Valenciennes', + modeIcon: 'train', + modeLabel: 'Train TER HDF', + trainNumber: '', + trainBadge: 'K60', + voie: '-', + perturbationType: 'information', + perturbationDetails: 'Information', + operator: 'SNCF Voyageurs', + dateIso: '2026-09-20', + stops: [ + { departTime: '17:05', gareNom: 'Lille Flandres', voie: '-', isDepart: true }, + { departTime: '17:45', gareNom: 'Valenciennes', voie: '3', isArrivee: true } + ] + } +]; + +// ─── DIJON DEFAULT DATASET ─── +const DEFAULT_DIJON_DEPARTURES: DepartureRow[] = [ + { + id: 'd1', + departTime: '15:30', + destination: 'Auxerre Saint-Gervais', + modeIcon: 'bus', + modeLabel: 'Car TER', + trainNumber: '79006', + voie: '-', + perturbationType: 'information', + perturbationDetails: 'Car supplémentaire', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-20', + stops: [ + { departTime: '15:30', gareNom: 'Dijon', voie: '-' }, + { departTime: '16:15', gareNom: 'Sombernon', voie: '-' }, + { departTime: '17:00', gareNom: 'Avallon', voie: '-' }, + { departTime: '17:45', gareNom: 'Auxerre Saint-Gervais', voie: '-' }, + ] + }, + { + id: 'd4', + departTime: '15:47', + destination: 'Nevers', + modeIcon: 'train', + modeLabel: 'Train MOBIGO', + trainNumber: '893019', + trainBadge: 'K6', + voie: '-', + operator: 'SNCF Voyageurs - 1187', + dateIso: '2026-09-20', + stops: [ + { departTime: '15:47', gareNom: 'Dijon', voie: '-', isDepart: true }, + { departTime: '16:08', gareNom: 'Beaune', voie: 'A' }, + { departTime: '16:35', gareNom: 'Montchanin', voie: '2' }, + { departTime: '16:42', gareNom: 'Le Creusot', voie: '2' }, + { departTime: '16:58', gareNom: 'Étang', voie: '2' }, + { departTime: '17:12', gareNom: 'Luzy', voie: '2' }, + { departTime: '17:30', gareNom: 'Cercy-la-Tour', voie: '2' }, + { departTime: '17:40', gareNom: 'Decize', voie: 'A' }, + { departTime: '17:55', gareNom: 'Imphy', voie: '2' }, + { departTime: '18:06', gareNom: 'Nevers', voie: '-', isArrivee: true }, + ] + }, + { + id: 'd10', + departTime: '16:49', + destination: 'Lyon Part Dieu', + modeIcon: 'train', + modeLabel: 'Train TER', + trainNumber: '17775', + voie: '-', + perturbationType: 'information', + perturbationDetails: 'Information voyageurs', + operator: 'SNCF Voyageurs', + dateIso: '2026-09-20', + stops: [ + { departTime: '16:49', gareNom: 'Dijon', voie: '-' }, + { departTime: '18:30', gareNom: 'Lyon Part Dieu', voie: 'A' }, + ] + }, + { + id: 'd17', + departTime: '17:50', + revisedTime: '18:00', + delayText: 'Retard estimé de 10 min (Travaux sur la voie)', + destination: 'Metz', + modeIcon: 'train', + modeLabel: 'Train TGV INOUI', + trainNumber: '5500', + voie: '-', + perturbationType: 'retard', + perturbationDetails: 'Retard estimé de 10 min : Travaux sur la voie', + operator: 'SNCF Voyageurs', + dateIso: '2026-09-20', + stops: [ + { departTime: '18:00', gareNom: 'Dijon', voie: '-' }, + { departTime: '20:30', gareNom: 'Metz Ville', voie: '1' }, + ] + } +]; + +export default function ProchainsDepartsClient({ + regionSlug = 'bourgogne-franche-comte', + menuStructure +}: ProchainsDepartsClientProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + + // Dynamic regional theme + const theme = getRegionTheme(regionSlug); + + // URL Query Param state for selected station + const stationParam = searchParams.get('gare') || searchParams.get('station') || ''; + + // Form search state + const [stationQuery, setStationQuery] = useState(stationParam); + const [selectedStation, setSelectedStation] = useState(stationParam); + const [activeTabType, setActiveTabType] = useState<'departs' | 'arrivees'>('departs'); + const [activeSubTab, setActiveSubTab] = useState<'departs' | 'arrivees'>('departs'); + + // Autocomplete stations list + const [stationsList, setStationsList] = useState([]); + const [filteredOptions, setFilteredOptions] = useState([]); + const [showDropdown, setShowDropdown] = useState(false); + + // Departures list state & Travaux state + const [departures, setDepartures] = useState([]); + const [arrivals, setArrivals] = useState([]); + const [travauxList, setTravauxList] = useState([]); + const [loading, setLoading] = useState(false); + + // Accordion open detail ID + const [expandedId, setExpandedId] = useState(null); + + // Get current time as HH:MM string + const getCurrentTimeStr = (): string => { + const now = new Date(); + return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; + }; + + // Get today's date as YYYY-MM-DD + const getTodayIso = (): string => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + }; + + // Filter out past departures (if dateIso is today and departTime < current time) + const filterPastItems = (items: T[], timeKey: keyof T): T[] => { + const todayIso = getTodayIso(); + const nowStr = getCurrentTimeStr(); + return items.filter(item => { + // If dateIso is today, check if time has passed + if (item.dateIso === todayIso) { + const timeVal = item[timeKey] as unknown as string; + if (timeVal && timeVal < nowStr) return false; + } + // Future dates or no dateIso: always show + return true; + }); + }; + + // Generate arrivals from full horaires data (trains arriving AT this station) + const generateArrivals = (allDepartureRows: DepartureRow[], stationName: string): ArrivalRow[] => { + const cleanName = stationName.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim(); + const arrList: ArrivalRow[] = []; + + allDepartureRows.forEach((dep) => { + const stops = dep.stops; + if (!stops || stops.length < 2) return; + + // Find this station in stops + const stationIdx = stops.findIndex(s => { + const sClean = s.gareNom.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim(); + return sClean.includes(cleanName) || cleanName.includes(sClean); + }); + + // For arrivals, the station must NOT be the first stop (it must arrive there from somewhere) + // stationIdx > 0: train arrives at this station from somewhere else + if (stationIdx > 0) { + const stationStop = stops[stationIdx]; + const firstStop = stops[0]; + // Use arriveeTime if available, otherwise use departTime of that stop + const arrTime = stationStop.arriveeTime || dep.departTime; + + arrList.push({ + id: `arr-${dep.id}`, + arrivalTime: arrTime, + revisedTime: dep.revisedTime, + delayText: dep.delayText, + isSupprime: dep.isSupprime, + provenance: firstStop.gareNom, + modeIcon: dep.modeIcon, + modeLabel: dep.modeLabel, + trainNumber: dep.trainNumber, + trainBadge: dep.trainBadge, + operator: dep.operator, + voie: stationStop.voie || '-', + dateIso: dep.dateIso, + dateLabel: dep.dateLabel, + perturbationType: dep.perturbationType, + perturbationDetails: dep.perturbationDetails, + stops: stops, + }); + } + }); + + arrList.sort((a, b) => a.arrivalTime.localeCompare(b.arrivalTime)); + return arrList; + }; + + // Fetch stations list from Supabase + useEffect(() => { + const fetchGares = async () => { + try { + const { data } = await supabase.from('gares').select('id, nom, type').order('nom'); + if (data && data.length > 0) { + setStationsList(data); + } else { + setStationsList([ + { id: '1', nom: 'Brazey-en-Plaine', type: 'ville' }, + { id: '2', nom: 'Lille Flandres', type: 'ville' }, + { id: '3', nom: 'Dijon', type: 'ville' }, + { id: '4', nom: 'Auxerre Saint-Gervais', type: 'ville' }, + { id: '5', nom: 'Besançon Viotte', type: 'ville' }, + { id: '6', nom: 'Nevers', type: 'ville' }, + { id: '7', nom: 'Mâcon', type: 'interurbaine' }, + { id: '8', nom: 'Chalon-sur-Saône', type: 'ville' }, + { id: '9', nom: 'Paris Gare de Lyon', type: 'ville' }, + ]); + } + } catch (err) { + console.error('Error fetching gares:', err); + } + }; + fetchGares(); + }, []); + + // Update selectedStation when URL changes + useEffect(() => { + if (stationParam) { + setSelectedStation(stationParam); + setStationQuery(stationParam); + if (stationParam.toLowerCase().includes('brazey')) { + setExpandedId('b1'); + } else if (stationParam.toLowerCase().includes('dijon')) { + setExpandedId('d4'); + } + } + }, [stationParam]); + + // Handle Station Search submit & URL update + const handleShowTimetable = (stationName: string) => { + if (!stationName) return; + setSelectedStation(stationName); + setShowDropdown(false); + + const newParams = new URLSearchParams(searchParams.toString()); + newParams.set('gare', stationName); + + const targetUrl = regionSlug + ? `/${regionSlug}/se-deplacer/prochains-departs?${newParams.toString()}` + : `/se-deplacer/prochains-departs?${newParams.toString()}`; + + router.push(targetUrl); + }; + + // Check train circulation on target date (jours_personnalises) + const checkCirculation = (item: any, targetIsoDate: string = '2026-09-20'): boolean => { + if (item.actif === false) return false; + + if (Array.isArray(item.jours_personnalises) && item.jours_personnalises.length > 0) { + const jpMatch = item.jours_personnalises.find((jp: any) => { + const d = (jp.date || jp.jour || '').slice(0, 10); + return d === targetIsoDate; + }); + if (jpMatch) return Boolean(jpMatch.circule); + } + + return false; + }; + + // Quai display logic: Ville (<= 30 min) vs Interurbaine (<= 12 hours) + const formatQuaiDisplay = (rawQuai: string | null | undefined, depTimeStr: string, stationType: string = 'ville'): string => { + if (!rawQuai || rawQuai.trim() === '' || rawQuai === '-') return '-'; + + const now = new Date(); + const nowTotalMins = now.getHours() * 60 + now.getMinutes(); + const [depH, depM] = depTimeStr.split(':').map(Number); + const depTotalMins = depH * 60 + depM; + const diffMins = depTotalMins - nowTotalMins; + + const thresholdMins = stationType === 'interurbaine' ? 720 : 30; + + if (diffMins >= 0 && diffMins <= thresholdMins) { + return rawQuai.trim(); + } + return '-'; + }; + + // Line Badge BG color helper + const getLineBadgeBg = (badge: string) => { + if (!badge) return '#006837'; + const upper = badge.toUpperCase(); + if (upper.startsWith('P')) return '#006837'; + if (upper.startsWith('C')) return '#0066a4'; + if (upper.startsWith('K')) return '#b81d77'; + return '#006837'; + }; + + // Fetch departures for selectedStation with strict perturbation date matching + useEffect(() => { + if (!selectedStation) { + setDepartures([]); + setTravauxList([]); + return; + } + + const loadDepartures = async () => { + setLoading(true); + try { + const cleanName = selectedStation.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); + + // 1. Query Gares for type + const { data: gData } = await supabase.from('gares').select('id, nom, type'); + const gMap: Record = {}; + let currentStationType = 'ville'; + + if (gData) { + gData.forEach((g: any) => { + gMap[g.id] = g.nom; + const gClean = g.nom.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); + if (gClean === cleanName || gClean.includes(cleanName) || cleanName.includes(gClean)) { + currentStationType = g.type || 'ville'; + } + }); + } + + // 2. Query Perturbations from Supabase + const { data: pertData } = await supabase + .from('perturbations') + .select('*') + .eq('actif', true) + .is('deleted_at', null); + + // 3. Query Travaux from Supabase + const { data: trData } = await supabase + .from('travaux') + .select('*') + .eq('actif', true) + .is('deleted_at', null); + + if (trData && trData.length > 0) { + setTravauxList(trData.map((t: any) => ({ + id: t.id?.toString(), + titre: t.titre || "Travaux d'entretien des voies", + description: (t.description || '').replace(/<[^>]*>?/gm, ''), + ligne_nom: t.ligne_nom || '', + }))); + } else { + setTravauxList([]); + } + + // 4. Query Horaires from Supabase + const { data: hData } = await supabase + .from('horaires') + .select('*') + .eq('actif', true) + .is('deleted_at', null); + + if (hData && hData.length > 0) { + const processForDate = (targetDateStr: string, isTomorrow: boolean = false) => { + const list: DepartureRow[] = []; + const allRowsForArrivals: DepartureRow[] = []; + let dateLabelStr: string | undefined = undefined; + + if (isTomorrow) { + const d = new Date(targetDateStr); + dateLabelStr = d.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }); + dateLabelStr = dateLabelStr.charAt(0).toUpperCase() + dateLabelStr.slice(1); + } + + hData.forEach((h: any, idx: number) => { + if (!checkCirculation(h, targetDateStr)) return; + + let gares = []; + if (typeof h.gares_desservies === 'string') { + try { gares = JSON.parse(h.gares_desservies); } catch (e) {} + } else if (Array.isArray(h.gares_desservies)) { + gares = h.gares_desservies; + } + + const stopIdx = gares.findIndex((g: any) => { + const gNom = (g.gare_nom || gMap[g.gare_id] || '').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); + return gNom.includes(cleanName) || cleanName.includes(gNom); + }); + + if (stopIdx !== -1) { + const stop = gares[stopIdx]; + const isTerminus = stop.is_arrivee || (stopIdx === gares.length - 1 && !stop.heure_depart) || !stop.heure_depart; + + const depTime = (stop.heure_depart || stop.heure_arrivee || '').slice(0, 5) || '15:00'; + const rawQuai = stop.quai || stop.quai_reel; + const quaiVal = formatQuaiDisplay(rawQuai, depTime, currentStationType); + + const matchingPert = pertData?.find((p: any) => { + const trainMatch = (p.horaire_id === h.id || String(p.horaire_id) === String(h.numero_train)); + if (!trainMatch) return false; + return isPerturbationActiveOnDate(p, targetDateStr); + }); + + let revisedTimeStr: string | undefined = undefined; + let delayTextStr: string | undefined = undefined; + let isSupp = false; + let pertTypeStr: string | undefined = undefined; + let pertDetailsStr: string | undefined = undefined; + + if (matchingPert) { + pertTypeStr = matchingPert.type_perturbation || 'information'; + pertDetailsStr = matchingPert.cause_details || matchingPert.cause || 'Information voyageurs'; + + if (matchingPert.type_perturbation === 'suppression') { + isSupp = true; + delayTextStr = `Train supprimé : ${matchingPert.cause_details || 'Incident'}`; + } else if (matchingPert.type_perturbation === 'retard' || matchingPert.duree_retard_minutes) { + const delayMins = matchingPert.duree_retard_minutes || 10; + const [hH, hM] = depTime.split(':').map(Number); + let revTotalMins = hH * 60 + hM + delayMins; + const revH = Math.floor(revTotalMins / 60) % 24; + const revM = revTotalMins % 60; + revisedTimeStr = `${String(revH).padStart(2, '0')}:${String(revM).padStart(2, '0')}`; + delayTextStr = `Retard estimé de ${delayMins} min (${matchingPert.cause_details || 'Incident'})`; + } + } + + const stopsMapped = gares.map((g: any, gIdx: number) => { + let impacts = []; + if (typeof matchingPert?.impacts_arrets === 'string') { + try { impacts = JSON.parse(matchingPert.impacts_arrets); } catch (e) {} + } else if (Array.isArray(matchingPert?.impacts_arrets)) { + impacts = matchingPert.impacts_arrets; + } + + let stopPert = undefined; + if (impacts.length > 0) { + stopPert = impacts.find((a: any) => a.gare_id === g.gare_id || a.gare_nom === g.gare_nom); + } + + return { + departTime: (g.heure_depart || g.heure_arrivee || '').slice(0, 5), + arriveeTime: (g.heure_arrivee || g.heure_depart || '').slice(0, 5), + gareNom: g.gare_nom || gMap[g.gare_id] || '', + voie: (g.quai && String(g.quai).trim() !== '') ? String(g.quai).trim() : '-', + isDepart: gIdx === 0, + isArrivee: gIdx === gares.length - 1, + perturbationType: stopPert ? stopPert.type_perturbation : undefined, + perturbationDetails: stopPert ? stopPert.cause_arret : undefined, + isNouvelleOrigine: stopPert ? stopPert.is_nouvelle_origine : false, + isNouveauTerminus: stopPert ? stopPert.is_nouveau_terminus : false, + retardMinutes: stopPert ? stopPert.retard_minutes : 0 + }; + }); + + const rowData: DepartureRow = { + id: h.id || `h-${idx}-${targetDateStr}`, + departTime: depTime, + revisedTime: revisedTimeStr, + delayText: delayTextStr, + isSupprime: isSupp, + destination: isTerminus + ? (gares[0]?.gare_nom || gMap[gares[0]?.gare_id] || 'Origine') + : (h.gare_arrivee_nom || gares[gares.length - 1]?.gare_nom || 'Terminus'), + modeIcon: (h.type_train || '').toLowerCase().includes('car') ? 'bus' : 'train', + modeLabel: h.type_train || 'Train TER', + trainNumber: h.numero_train || `89${idx + 301}`, + trainBadge: h.badge_ligne || undefined, + voie: quaiVal, + rawQuai: rawQuai, + dateIso: targetDateStr, + dateLabel: dateLabelStr, + perturbationType: pertTypeStr, + perturbationDetails: pertDetailsStr, + operator: h.operateur || 'SNCF Voyageurs - 1187', + stops: stopsMapped, + }; + + allRowsForArrivals.push(rowData); + + if (!isTerminus) { + list.push(rowData); + } + } + }); + + list.sort((a, b) => a.departTime.localeCompare(b.departTime)); + return { list, allRowsForArrivals }; + }; + + const todayIso = getTodayIso(); + let { list: todayDeps, allRowsForArrivals: todayArrRows } = processForDate(todayIso, false); + + let filteredDeps = filterPastItems(todayDeps, 'departTime'); + let genArrivals = generateArrivals(todayArrRows, selectedStation); + let filteredArr = filterPastItems(genArrivals, 'arrivalTime'); + + // If less than 20, load tomorrow + if (filteredDeps.length < 20 || filteredArr.length < 20) { + const tomDate = new Date(); + tomDate.setDate(tomDate.getDate() + 1); + const tomorrowIso = `${tomDate.getFullYear()}-${String(tomDate.getMonth() + 1).padStart(2, '0')}-${String(tomDate.getDate()).padStart(2, '0')}`; + + const { list: tomDeps, allRowsForArrivals: tomArrRows } = processForDate(tomorrowIso, true); + + if (filteredDeps.length < 20) { + filteredDeps = [...filteredDeps, ...tomDeps]; + } + if (filteredArr.length < 20) { + const tomGenArr = generateArrivals(tomArrRows, selectedStation); + filteredArr = [...filteredArr, ...tomGenArr]; + } + } + + setDepartures(filteredDeps.slice(0, 20)); + setArrivals(filteredArr.slice(0, 20)); + setLoading(false); + return; + } + + // Fallback demo datasets matching Image 1 & Image 2 + let fallbackDeps: DepartureRow[] = []; + if (cleanName.includes('brazey')) { + fallbackDeps = DEFAULT_BRAZEY_DEPARTURES; + setExpandedId('b1'); + } else if (cleanName.includes('lille')) { + fallbackDeps = DEFAULT_LILLE_DEPARTURES; + } else if (cleanName.includes('dijon')) { + fallbackDeps = DEFAULT_DIJON_DEPARTURES; + setExpandedId('d4'); + } else { + // Custom station fallback + fallbackDeps = DEFAULT_DIJON_DEPARTURES.map((d, i) => ({ + ...d, + id: `cust-${i}`, + stops: d.stops.map((st, idx) => idx === 0 ? { ...st, gareNom: selectedStation } : st) + })); + } + const filteredFallbackDeps = filterPastItems(fallbackDeps, 'departTime'); + setDepartures(filteredFallbackDeps); + const fallbackArrivals = generateArrivals(fallbackDeps, selectedStation); + const filteredFallbackArr = filterPastItems(fallbackArrivals, 'arrivalTime'); + setArrivals(filteredFallbackArr); + + } catch (err) { + console.error('Error fetching departures:', err); + setDepartures(DEFAULT_DIJON_DEPARTURES); + } finally { + setLoading(false); + } + }; + + loadDepartures(); + }, [selectedStation]); + + const handleSearchInputChange = (val: string) => { + setStationQuery(val); + if (!val.trim()) { + setFilteredOptions([]); + setShowDropdown(false); + return; + } + const filtered = stationsList.filter(s => + s.nom.toLowerCase().includes(val.toLowerCase()) + ).slice(0, 8); + setFilteredOptions(filtered); + setShowDropdown(true); + }; + + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+
+ {/* Header with dynamic region theme */} + + + {/* ─── BREADCRUMB ─── */} +
+ + home + + › + + Prochains départs + + {selectedStation && ( + <> + › + Gare {selectedStation} + + )} +
+ + {/* ─── MAIN CONTENT ─── */} +
+ + {/* STEP 1: STATION SEARCH VIEW */} + {!selectedStation ? ( +
+ + {/* Green Train + Clock Pictogram */} +
+
+
+ train + access_time +
+
+
+ + {/* Title & Subtitle */} +

+ Prochains départs +

+

+ Tableaux des départs et arrivées de plus de 5000 gares +

+ + {/* Toggle Switch (Départs / Arrivées) */} +
+ + +
+ + {/* Station Search Input Box with Autocomplete */} +
+
+ search + handleSearchInputChange(e.target.value)} + onFocus={() => stationQuery && setShowDropdown(true)} + placeholder="Rechercher une gare" + className="w-full bg-transparent text-[14px] text-gray-900 outline-none placeholder:text-gray-400" + /> + {stationQuery && ( + + )} +
+ + {/* Autocomplete Dropdown */} + {showDropdown && filteredOptions.length > 0 && ( +
+ {filteredOptions.map((st) => ( +
{ + setStationQuery(st.nom); + setShowDropdown(false); + handleShowTimetable(st.nom); + }} + className="px-4 py-2.5 text-[14px] text-gray-800 hover:bg-[#eef5e6] hover:text-[#006837] cursor-pointer flex items-center justify-between border-b border-gray-50 last:border-0" + > + {st.nom} + chevron_right +
+ ))} +
+ )} +
+ + {/* Action Button: "Afficher les horaires" */} + + +
+ ) : ( + /* STEP 2: TIMETABLE & DETAILED HORAIRE VIEW (STRICTLY IMAGE 1 & IMAGE 2 DESIGN) */ +
+ + {/* Header Title Row (Image 1) */} +
+
+

+ Prochains {activeSubTab === 'arrivees' ? 'arrivées' : 'départs'} +

+

+ Gare {selectedStation} +

+
+ + {/* Top Right Action Links (Image 1) */} +
+ + +
+
+ + {/* TRAVAUX INFO BANNER (If active travaux exist) */} + {travauxList.length > 0 && ( +
+
+ construction + Info Travaux : {travauxList[0].titre} +
+

+ {travauxList[0].description} +

+
+ )} + + {/* Sub Header Tabs: Départs SNCF / Arrivées SNCF (Image 1) */} +
+ + +
+ + {/* ─── MAIN TABLE (STRICTLY IMAGE 1 & IMAGE 2) ─── */} +
+ + {/* Table Header (Top yellow-green border line matching Image 1) */} +
+ {activeSubTab === 'arrivees' ? 'Arrivée' : 'Départ'} + {activeSubTab === 'arrivees' ? 'Provenance' : 'Destination'} + Mode + Voie + +
+ + {/* ════ DÉPARTS TABLE ════ */} + {activeSubTab === 'departs' && ( +
+ {loading ? ( +
+ refresh +

Chargement des prochains départs en gare de {selectedStation}...

+
+ ) : departures.length > 0 ? ( + departures.map((dep, dIdx) => { + const isExpanded = expandedId === dep.id; + const showDateHeader = dep.dateLabel && (dIdx === 0 || departures[dIdx - 1].dateIso !== dep.dateIso); + + // Resolve perturbation banner style + const pertStyle = dep.perturbationType + ? getPerturbationBannerStyle(dep.perturbationType) + : null; + + return ( + + + {/* Date Separator Row (Image 1: e.g. "Lundi 21 septembre") */} + {showDateHeader && ( +
+ {dep.dateLabel} +
+ )} + +
+ + {/* Main Departure Row */} +
setExpandedId(isExpanded ? null : dep.id)} + className="px-3.5 py-3 sm:px-5 sm:py-3 grid grid-cols-2 sm:grid-cols-[72px_1fr_260px_72px_52px] items-center gap-2 sm:gap-0 hover:bg-gray-50/60 cursor-pointer text-[14px]" + > + {/* Time */} +
+ {dep.revisedTime ? ( +
+ + {dep.departTime} + + + {dep.revisedTime} + +
+ ) : ( + + {dep.departTime} + + )} +
+ + {/* Voie & Toggle (Mobile) */} +
+ + Voie {dep.voie} + +
+ shopping_basket + + {isExpanded ? 'expand_less' : 'expand_more'} + +
+
+ + {/* Destination */} +
+ {dep.destination} +
+ + {/* Mode Icon + Label + Badge */} +
+ + {dep.modeIcon === 'bus' ? 'directions_bus' : 'directions_railway'} + + {dep.modeLabel} {dep.trainNumber} + {dep.trainBadge && ( + + {dep.trainBadge} + + )} +
+ + {/* Voie (Desktop) */} +
+ {dep.voie} +
+ + {/* Green Pill Button (Image 1: basket + chevron) */} +
+
+ shopping_basket + + {isExpanded ? 'expand_less' : 'expand_more'} + +
+
+
+ + {/* ─── PERTURBATION INFO BANNER (Image 1: light blue bg + wavy notch) ─── */} + {pertStyle && !isExpanded && ( +
+ {/* Left-side wavy notch chevrons (Image 1 style) */} +
+ + + +
+
+ + {pertStyle.iconText} + + + {dep.delayText || dep.perturbationDetails || pertStyle.badgeLabel} + +
+
+ )} + + {/* ─── EXPANDED DETAIL VIEW (Image 2 style) ─── */} + {isExpanded && ( +
+ + {/* Expanded Banner for Perturbation (Image 2 style) */} + {pertStyle && ( +
+
+
+ + {pertStyle.iconText === '!' ? 'error' : 'schedule'} + + + {pertStyle.badgeLabel} + +
+ {dep.perturbationDetails && ( +
+ {dep.perturbationDetails} +
+ )} +
+
+ )} + +

+ Liste des gares +

+

+ desservies par le {dep.modeIcon === 'bus' ? 'car' : 'train'} TER {dep.trainNumber} + {dep.trainBadge && ( + + {dep.trainBadge} + + )} +

+

+ {dep.operator || 'Opéré par SNCF Voyageurs - 1187'} +

+ + {/* Stops Timeline (Image 1 style) */} +
+
+ + Départ + Gare + Voie +
+ +
+ {/* Vertical timeline line */} +
+ + {dep.stops.map((stop, sIdx) => { + const isFirst = sIdx === 0; + return ( +
+ {/* Radio Button Column */} +
+ {!isFirst && ( +
+ )} +
+ + {/* Time Column */} + + {stop.departTime} + + + {/* Timeline & Station Name Column */} +
+
+ {/* Yellow Timeline Dot */} + + + + {stop.gareNom} + +
+ + {/* Perturbation sub-block */} + {stop.perturbationType && ( +
+
+ + {stop.perturbationType === 'arret_supprime' ? 'error' : stop.perturbationType === 'retard' ? 'schedule' : 'info'} + + + {stop.perturbationType === 'arret_supprime' ? 'Arrêt supprimé' + : stop.perturbationType === 'retard' ? `Retard estimé de ${stop.retardMinutes || 0} min` + : stop.isNouvelleOrigine ? 'Nouvelle gare de départ' + : stop.isNouveauTerminus ? 'Nouveau terminus' + : 'Information'} + +
+ {stop.perturbationDetails && ( + {stop.perturbationDetails} + )} +
+ )} +
+ + {/* Voie Column */} + + {stop.voie} + +
+ ); + })} +
+
+ + {/* Buy Ticket Widget (Image 1 style) */} +
+

+ Vous souhaitez acheter un trajet ? +

+

+ Sélectionnez votre gare d'arrivée dans la liste ci-dessus +

+ +
+
+ person + 1 Voyageur, Sans carte +
+ edit +
+
+
+ )} + +
+ + ); + }) + ) : ( +
+ Aucun départ trouvé en gare de {selectedStation}. +
+ )} +
+ )} + + {/* ════ ARRIVÉES TABLE ════ */} + {activeSubTab === 'arrivees' && ( +
+ {loading ? ( +
+ refresh +

Chargement des prochaines arrivées en gare de {selectedStation}...

+
+ ) : arrivals.length > 0 ? ( + arrivals.map((arr, aIdx) => { + const isExpanded = expandedId === arr.id; + const showDateHeader = arr.dateLabel && (aIdx === 0 || arrivals[aIdx - 1].dateIso !== arr.dateIso); + + const pertStyle = arr.perturbationType + ? getPerturbationBannerStyle(arr.perturbationType) + : null; + + return ( + + + {/* Date Separator */} + {showDateHeader && ( +
+ {arr.dateLabel} +
+ )} + +
+ {/* Main Arrival Row */} +
setExpandedId(isExpanded ? null : arr.id)} + className="px-3.5 py-3 sm:px-5 sm:py-3 grid grid-cols-2 sm:grid-cols-[72px_1fr_260px_72px_52px] items-center gap-2 sm:gap-0 hover:bg-gray-50/60 cursor-pointer text-[14px]" + > + {/* Arrival Time */} +
+ {arr.revisedTime ? ( +
+ + {arr.arrivalTime} + + + {arr.revisedTime} + +
+ ) : ( + + {arr.arrivalTime} + + )} +
+ + {/* Voie & Toggle (Mobile) */} +
+ + Voie {arr.voie} + +
+ shopping_basket + + {isExpanded ? 'expand_less' : 'expand_more'} + +
+
+ + {/* Provenance */} +
+ {arr.provenance} +
+ + {/* Mode Icon + Label + Badge */} +
+ + {arr.modeIcon === 'bus' ? 'directions_bus' : 'directions_railway'} + + {arr.modeLabel} {arr.trainNumber} + {arr.trainBadge && ( + + {arr.trainBadge} + + )} +
+ + {/* Voie (Desktop) */} +
+ {arr.voie} +
+ + {/* Green Pill Button */} +
+
+ shopping_basket + + {isExpanded ? 'expand_less' : 'expand_more'} + +
+
+
+ + {/* Perturbation info banner */} + {pertStyle && !isExpanded && ( +
+
+ + + +
+
+ + {pertStyle.iconText} + + + {arr.delayText || arr.perturbationDetails || pertStyle.badgeLabel} + +
+
+ )} + + {/* Expanded Detail (Image 1 style) */} + {isExpanded && ( +
+ + {/* Expanded Banner for Perturbation (Image 2 style) */} + {pertStyle && ( +
+
+
+ + {pertStyle.iconText === '!' ? 'error' : 'schedule'} + + + {pertStyle.badgeLabel} + +
+ {arr.perturbationDetails && ( +
+ {arr.perturbationDetails} +
+ )} +
+
+ )} + +

+ Liste des gares +

+

+ desservies par le {arr.modeIcon === 'bus' ? 'car' : 'train'} TER {arr.trainNumber} + {arr.trainBadge && ( + + {arr.trainBadge} + + )} +

+

+ {arr.operator || 'Opéré par SNCF Voyageurs - 1187'} +

+ + {/* Stops Timeline (Image 1 & 3 style) */} +
+
+ + Départ + Gare + Voie +
+ +
+ {/* Vertical timeline line */} +
+ + {arr.stops.map((stop, sIdx) => { + const isLast = sIdx === arr.stops.length - 1; + return ( +
+ {/* Radio Button Column */} +
+ {!isLast && ( +
+ )} +
+ + {/* Time Column */} + + {stop.departTime} + + + {/* Timeline & Station Name Column */} +
+
+ {/* Yellow Timeline Dot */} + + + + {stop.gareNom} + +
+ + {/* Perturbation sub-block */} + {stop.perturbationType && ( +
+
+ + {stop.perturbationType === 'arret_supprime' ? 'error' : stop.perturbationType === 'retard' ? 'schedule' : 'info'} + + + {stop.perturbationType === 'arret_supprime' ? 'Arrêt supprimé' + : stop.perturbationType === 'retard' ? `Retard estimé de ${stop.retardMinutes || 0} min` + : stop.isNouvelleOrigine ? 'Nouvelle gare de départ' + : stop.isNouveauTerminus ? 'Nouveau terminus' + : 'Information'} + +
+ {stop.perturbationDetails && ( + {stop.perturbationDetails} + )} +
+ )} +
+ + {/* Voie Column */} + + {stop.voie} + +
+ ); + })} +
+
+ + {/* Buy Ticket Widget (Image 1 style) */} +
+

+ Vous souhaitez acheter un trajet ? +

+

+ Sélectionnez votre gare d'arrivée dans la liste ci-dessus +

+ +
+
+ person + 1 Voyageur, Sans carte +
+ edit +
+
+
+ )} + +
+ + ); + }) + ) : ( +
+ Aucune arrivée trouvée en gare de {selectedStation}. +
+ )} +
+ )} + +
+ +
+ )} + +
+
+ + {/* ─── SCROLL TO TOP BUTTON ─── */} +
+ +
+ + {/* ─── FOOTER ─── */} +
+
+ + {/* Region Switcher */} +
+ +
+ + {/* Social Icons */} +
+ facebook + X + photo_camera + play_circle + chat +
+ + {/* Links Row */} +
+ CGU/CGV TRAIN Mobigo + • + CGU / CGV Site Internet + • + Confidentialité/Cookies + • + Gares + • + Lignes + • + MOBIGO + • + Accessibilité : partiellement conforme + • + Mentions légales + • + Plan du site + • + Contacts +
+ +
+ Demande d'information et réclamations • Données personnelles +
+ +
+
+
+ ); +} diff --git a/src/components/RegionContent.tsx b/src/components/RegionContent.tsx index 168b22c..f0850a5 100644 --- a/src/components/RegionContent.tsx +++ b/src/components/RegionContent.tsx @@ -1,8 +1,13 @@ "use client"; import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { supabase } from '@/lib/supabase'; +import { getRegionTheme } from '@/lib/regionTheme'; +import SiteHeader from '@/components/SiteHeader'; +import HeaderMenu from '@/components/HeaderMenu'; +import TimetableSearchForm from '@/components/TimetableSearchForm'; interface RegionData { nom_site: string; @@ -39,6 +44,21 @@ export interface ActualiteItem { region_id?: string; } +export interface EvenementItem { + id: string | number; + titre: string; + description?: string; + lieu?: string | null; + gare_id?: string | null; + date_debut?: string; + date_fin?: string | null; + image_url?: string | null; + lien_externe?: string | null; + actif?: boolean; + visible_site?: boolean; + region_id?: string; +} + export interface HoraireGareDesservie { gare_id?: string; gare_nom: string; @@ -492,16 +512,27 @@ export default function RegionContent({ regionData, initialInfoTrafic = [], initialActualites = [], + initialEvenements = [], + initialMenuStructure = null, initialHoraires = [] }: { regionSlug: string; regionData: RegionData; initialInfoTrafic?: InfoTraficItem[]; initialActualites?: ActualiteItem[]; + initialEvenements?: EvenementItem[]; + initialMenuStructure?: any; initialHoraires?: HoraireItem[]; }) { const router = useRouter(); + // Region candidates for alias support + const regionCandidates = Array.from(new Set([ + regionSlug, + regionSlug === 'sud-provence-alpes-cote-d-azur' ? 'provence-alpes-cote-azur' : '', + regionSlug === 'provence-alpes-cote-azur' ? 'sud-provence-alpes-cote-d-azur' : '', + ].filter(Boolean))); + // Dynamic Region Configuration const config = REGION_CONFIGS[regionSlug] || REGION_CONFIGS["sud-provence-alpes-cote-d-azur"]; const regionDisplayName = regionData?.nom_site @@ -509,28 +540,16 @@ export default function RegionContent({ : config.displayName; // State: Navigation tabs - const [activeTab, setActiveTab] = useState("Tarifs et cartes de réduction"); + const [activeTab, setActiveTab] = useState("Se déplacer"); - // State: Search Booking Box - const [departureStation, setDepartureStation] = useState(""); - const [arrivalStation, setArrivalStation] = useState(""); - const [showVia, setShowVia] = useState(false); - const [viaStation, setViaStation] = useState(""); - const [outwardDate, setOutwardDate] = useState("Mer. 26 août"); - const [outwardTime, setOutwardTime] = useState("01:00"); - const [hasReturn, setHasReturn] = useState(false); - const [returnDate, setReturnDate] = useState("Jeu. 27 août"); - const [returnTime, setReturnTime] = useState("18:00"); - const [passengerCount, setPassengerCount] = useState(1); - const [selectedDiscountCard, setSelectedDiscountCard] = useState("Sans carte"); - const [showPassengerModal, setShowPassengerModal] = useState(false); + // State: Search feedback const [searchSuccessMessage, setSearchSuccessMessage] = useState(""); // State: Prochains départs const [selectedStation, setSelectedStation] = useState(config.stations[0] || "Dijon"); const [expandedDepartures, setExpandedDepartures] = useState(false); - // State: Traffic info (Default to collapsed/non-extended mode) + // State: Traffic info const [trafficPage, setTrafficPage] = useState(1); const [isTrafficExpanded, setIsTrafficExpanded] = useState(false); @@ -556,10 +575,13 @@ export default function RegionContent({ } }, [regionSlug, config.stations]); - // 1. STATE & LIVE SUPABASE SYNC (ZERO MOCK) + // 1. STATE & LIVE SUPABASE SYNC (STRICTLY REGIONAL) const [trafficDataList, setTrafficDataList] = useState(initialInfoTrafic || []); const [actusDataList, setActusDataList] = useState(initialActualites || []); + const [evenementsDataList, setEvenementsDataList] = useState(initialEvenements || []); + const [menuStructure, setMenuStructure] = useState(initialMenuStructure || null); const [horairesDataList, setHorairesDataList] = useState(initialHoraires || []); + const [garesTypeMap, setGaresTypeMap] = useState>({}); useEffect(() => { if (initialInfoTrafic && initialInfoTrafic.length > 0) { @@ -568,11 +590,17 @@ export default function RegionContent({ if (initialActualites && initialActualites.length > 0) { setActusDataList(initialActualites); } + if (initialEvenements && initialEvenements.length > 0) { + setEvenementsDataList(initialEvenements); + } + if (initialMenuStructure) { + setMenuStructure(initialMenuStructure); + } if (initialHoraires && initialHoraires.length > 0) { setHorairesDataList(initialHoraires); } - // Client-side live fetch to guarantee 100% up-to-date DB data + // Client-side live fetch filtered strictly by region_id const fetchLiveSupabase = async () => { try { // 1. Info trafic @@ -580,6 +608,7 @@ export default function RegionContent({ .from('info_trafic') .select('*') .eq('actif', true) + .in('region_id', regionCandidates) .is('deleted_at', null) .order('created_at', { ascending: false }); if (tData) { @@ -591,6 +620,7 @@ export default function RegionContent({ .from('actualites') .select('*') .eq('actif', true) + .in('region_id', regionCandidates) .is('deleted_at', null) .order('date_publication', { ascending: false }) .order('created_at', { ascending: false }); @@ -598,11 +628,39 @@ export default function RegionContent({ setActusDataList(aData as ActualiteItem[]); } - // 3. Gares & Horaires - const { data: gData } = await supabase.from('gares').select('id, nom'); + // 3. Evenements + const { data: eData } = await supabase + .from('evenements') + .select('*') + .eq('actif', true) + .in('region_id', regionCandidates) + .is('deleted_at', null) + .order('date_debut', { ascending: false }); + if (eData) { + setEvenementsDataList(eData as EvenementItem[]); + } + + // 4. Menus régionaux + const { data: mData } = await supabase + .from('menus_regionaux') + .select('*') + .eq('actif', true) + .in('region_id', regionCandidates) + .limit(1); + if (mData && mData.length > 0) { + setMenuStructure(mData[0].structure); + } + + // 5. Gares & Horaires + const { data: gData } = await supabase.from('gares').select('id, nom, type'); const gMap: Record = {}; + const gTypeMap: Record = {}; if (gData) { - gData.forEach((g: any) => { gMap[g.id] = g.nom; }); + gData.forEach((g: any) => { + gMap[g.id] = g.nom; + gTypeMap[g.nom.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim()] = g.type || 'ville'; + }); + setGaresTypeMap(gTypeMap); } const { data: hData } = await supabase @@ -624,19 +682,13 @@ export default function RegionContent({ }; fetchLiveSupabase(); - }, [initialInfoTrafic, initialActualites, initialHoraires, regionSlug]); + }, [initialInfoTrafic, initialActualites, initialEvenements, initialMenuStructure, initialHoraires, regionSlug]); - // Filter traffic for this region or take all active DB records - const regionSpecificTraffics = trafficDataList.filter(t => - !t.region_id || t.region_id === regionSlug || - (regionSlug === 'provence-alpes-cote-azur' && t.region_id === 'sud-provence-alpes-cote-d-azur') || - (regionSlug === 'sud-provence-alpes-cote-d-azur' && t.region_id === 'provence-alpes-cote-azur') + // Strict regional filtering: NO fallback to other regions + const trafficItems: InfoTraficItem[] = trafficDataList.filter(t => + !t.region_id || regionCandidates.includes(t.region_id) ); - const trafficItems: InfoTraficItem[] = regionSpecificTraffics.length > 0 - ? regionSpecificTraffics - : trafficDataList; - const currentTrafficIndex = (trafficPage - 1) % (trafficItems.length || 1); const currentTraffic: InfoTraficItem | null = trafficItems.length > 0 ? trafficItems[currentTrafficIndex] @@ -659,18 +711,17 @@ export default function RegionContent({ return '#e65100'; }; - // Filter actualités for this region or take all active DB records - const regionSpecificActus = actusDataList.filter(a => - !a.region_id || a.region_id === regionSlug || - (regionSlug === 'provence-alpes-cote-azur' && a.region_id === 'sud-provence-alpes-cote-d-azur') || - (regionSlug === 'sud-provence-alpes-cote-d-azur' && a.region_id === 'provence-alpes-cote-azur') + // Strict regional actualités filter + const allActusList: ActualiteItem[] = actusDataList.filter(a => + !a.region_id || regionCandidates.includes(a.region_id) ); - const allActusList: ActualiteItem[] = regionSpecificActus.length > 0 - ? regionSpecificActus - : actusDataList; + // Strict regional evenements filter + const evenementsItems: EvenementItem[] = evenementsDataList.filter(e => + !e.region_id || regionCandidates.includes(e.region_id) + ); - // 2 dernières actualités mises en avant pour le Sub-hero (BDD Supabase) + // 2 dernières actualités mises en avant pour le Sub-hero const featuredOnly = allActusList.filter(a => a.mise_en_avant); const top2Featured: ActualiteItem[] = featuredOnly.length >= 2 ? featuredOnly.slice(0, 2) @@ -693,21 +744,19 @@ export default function RegionContent({ return `${year}-${month}-${day}`; }; - // Helper: Vérification des règles de circulation UNIQUEMENT via la colonne "jours_personnalises" + // Helper: Vérification des règles de circulation (uniquement "jours_personnalises") const isTrainCirculating = (h: HoraireItem, targetDate: string = getLocalDateStr()) => { if (h.actif === false) return false; - // Uniquement la colonne "jours_personnalises" : tableau d'objets avec { date, circule: true } - if (!Array.isArray(h.jours_personnalises) || h.jours_personnalises.length === 0) { - return false; + if (Array.isArray(h.jours_personnalises) && h.jours_personnalises.length > 0) { + const customDay = h.jours_personnalises.find(jp => { + const d = jp.date || jp.jour || ''; + return d === targetDate || d.slice(0, 10) === targetDate; + }); + if (customDay) return customDay.circule === true; } - const customDay = h.jours_personnalises.find(jp => { - const d = jp.date || jp.jour || ''; - return d === targetDate || d.slice(0, 10) === targetDate; - }); - - return customDay?.circule === true; + return false; }; // Filter and resolve departures for selectedStation taking into account gares_desservies & real quais @@ -720,7 +769,16 @@ export default function RegionContent({ // 1. Vérification de la circulation du train pour aujourd'hui if (!isTrainCirculating(h, todayDateStr)) continue; - const gares = Array.isArray(h.gares_desservies) ? h.gares_desservies : []; + let gares: any[] = []; + if (typeof h.gares_desservies === 'string') { + try { + gares = JSON.parse(h.gares_desservies); + } catch (e) { + gares = []; + } + } else if (Array.isArray(h.gares_desservies)) { + gares = h.gares_desservies; + } // 2. Recherche dans la balise / tableau "gares_desservies" du nom de la gare affichée const stopIndex = gares.findIndex(g => { @@ -739,8 +797,18 @@ export default function RegionContent({ const lastStop = gares[gares.length - 1]; const destination = h.gare_arrivee_nom || lastStop?.gare_nom || "Terminus"; const departureTime = (stop.heure_depart || '').slice(0, 5); - // Quai réel de l'horaire pour cette gare - const quaiVal = (stop.quai && String(stop.quai).trim() !== '') ? String(stop.quai).trim() : '-'; + + // Quai réel avec filtrage selon le type de gare (30min pour ville, 12h pour interurbaine) + const rawQuaiStr = (stop.quai && String(stop.quai).trim() !== '') ? String(stop.quai).trim() : '-'; + const currentStationType = garesTypeMap[selectedStationNorm] || (selectedStationNorm.includes('dijon') ? 'ville' : 'interurbaine'); + const [depH, depM] = departureTime.split(':').map(Number); + const depTotalMins = (depH || 0) * 60 + (depM || 0); + const nowForQuai = new Date(); + const refTotalMins = nowForQuai.getHours() * 60 + nowForQuai.getMinutes(); + const diffMins = depTotalMins - refTotalMins; + const thresholdMins = currentStationType === 'interurbaine' ? 720 : 30; + const quaiVal = (rawQuaiStr !== '-' && diffMins >= 0 && diffMins <= thresholdMins) ? rawQuaiStr : '-'; + const trainType = h.type_train || 'Train TER'; const trainNum = h.numero_train || ''; @@ -783,29 +851,16 @@ export default function RegionContent({ // Tri chronologique par heure de départ stationDepartures.sort((a, b) => a.scheduledTime.localeCompare(b.scheduledTime)); - // Swap departure & arrival - const handleSwapStations = () => { - const temp = departureStation; - setDepartureStation(arrivalStation); - setArrivalStation(temp); - }; + // Filter out past departures (where scheduledTime < current time) + const nowTimeStr = (() => { + const n = new Date(); + return `${String(n.getHours()).padStart(2, '0')}:${String(n.getMinutes()).padStart(2, '0')}`; + })(); + const filteredStationDepartures = stationDepartures.filter(d => d.scheduledTime >= nowTimeStr); - // Pre-fill search from destinations + // Pre-fill route selection and navigate to search const handleSelectRoute = (from: string, to: string) => { - setDepartureStation(from); - setArrivalStation(to); - window.scrollTo({ top: 100, behavior: 'smooth' }); - }; - - // Perform search - const handleSearchSubmit = () => { - if (!departureStation || !arrivalStation) { - setSearchSuccessMessage("Veuillez renseigner la gare de départ et d'arrivée."); - setTimeout(() => setSearchSuccessMessage(""), 4000); - return; - } - setSearchSuccessMessage(`Recherche lancée pour : ${departureStation} ➔ ${arrivalStation} le ${outwardDate}`); - setTimeout(() => setSearchSuccessMessage(""), 5000); + router.push(`/${regionSlug}/horaires`); }; // Add ticket to cart @@ -814,7 +869,7 @@ export default function RegionContent({ id: Date.now(), title: `${selectedStation} ➔ ${dep.destName} (${dep.scheduledTime})`, price: dep.price, - date: outwardDate + date: new Date().toLocaleDateString('fr-FR') }; setCartItems(prev => [...prev, newItem]); setShowCartDrawer(true); @@ -832,180 +887,50 @@ export default function RegionContent({
{/* ================================================================== */} - {/* 1. HEADER (RESPONSIVE MOBILE / TABLETTE / DESKTOP) */} + {/* 1. STICKY UNIFIED SITE HEADER COMPONENT (IMAGE 1 & 2) */} {/* ================================================================== */} -
-
- - {/* Left: Burger Menu (Mobile only) + SNCF VOYAGEURS Logo + Regional Brand Logo */} -
- {/* Burger Menu Button for Mobile & Tablet */} - + - {/* SNCF Voyageurs Logo */} -
window.scrollTo({ top: 0, behavior: 'smooth' })}> - - -
- - - {/* Regional Brand Logo (Database logo_header_url or Fallback) */} - {regionData?.logo_header_url ? ( -
- {regionDisplayName} -
- ) : regionSlug === "provence-alpes-cote-azur" || regionSlug === "sud-provence-alpes-cote-d-azur" ? ( -
- TER - Z - OU - ! -
- ) : regionSlug === "bourgogne-franche-comte" ? ( -
-
- - mobigo - - -
- - {config.tagline} - -
- ) : regionSlug === "bretagne" ? ( -
- Breizh - Go -
- ) : regionSlug === "centre-val-de-loire" ? ( -
- Ré - mi -
- ) : regionSlug === "occitanie" ? ( -
- liO - Train -
+ {/* ================================================================== */} + {/* 2. BANDEAU JAUNE ÉVÉNEMENT (CONFORME MAQUETTE IMAGE 1) */} + {/* ================================================================== */} + {evenementsItems.length > 0 && ( +
+
+ campaign + + {evenementsItems[0].titre?.toLowerCase().includes('patrimoine') + ? "Journées du patrimoine : allez-y en train, bus et car avec le pass 6€ !" + : `${evenementsItems[0].titre}${evenementsItems[0].description ? ` : ${evenementsItems[0].description}` : ''}`} + + {evenementsItems[0].lien_externe ? ( + + Billets en vente ici en nombre limité › + ) : ( -
- - {config.brandPrimary} - - - {config.brandSecondary} - -
+ + Billets en vente ici en nombre limité › + )}
- - {/* Right: Actions */} -
- {/* Desktop only: Commande */} - - -
- - {/* Panier (Mobile & Desktop) */} - - -
- - {/* Compte (Mobile & Desktop) */} - -
-
- - {/* ================================================================== */} - {/* 2. NAVIGATION BAR (DESKTOP & GRANDES TABLETTES) */} - {/* ================================================================== */} - + )} {/* ================================================================== */} {/* 3. HERO & SEARCH BOX (RESPONSIVE CONFORME IMAGE 1) */} @@ -1035,165 +960,11 @@ export default function RegionContent({
- {/* ─── BLOC DE RECHERCHE D'HORAIRES (SCALE EXACTE IMAGE 1) ─── */} -
- - {/* 1. Gare de départ */} -
-
- - place - - setDepartureStation(e.target.value)} - placeholder="Gare de départ" - className="w-full bg-transparent text-[14px] text-gray-800 placeholder-gray-500 outline-none font-normal" - /> -
- -
- - {/* 2. Gare d'arrivée */} -
- - place - - setArrivalStation(e.target.value)} - placeholder="Gare d'arrivée" - className="w-full bg-transparent text-[14px] text-gray-800 placeholder-gray-500 outline-none font-normal" - /> -
- - {/* + Via */} -
- -
- - {showVia && ( -
- alt_route - setViaStation(e.target.value)} - placeholder="Gare de passage (Via)" - className="w-full bg-transparent text-[13.5px] text-gray-800 placeholder-gray-500 outline-none" - /> -
- )} - - {/* 3. Aller */} -
-
- - calendar_today - -
- Aller - setOutwardDate(e.target.value)} - className="bg-transparent text-[13.5px] font-semibold text-gray-900 leading-tight outline-none w-28" - /> -
-
-
- setOutwardTime(e.target.value)} - className="w-14 bg-transparent text-left font-normal text-[14px] text-gray-900 outline-none" - /> -
- - {/* 4. Retour */} -
setHasReturn(!hasReturn)} - className="bg-[#f2f2f3] rounded-lg px-3.5 py-2.5 mb-2.5 flex items-center justify-between hover:bg-[#eaeaea] transition-colors cursor-pointer" - > -
- - calendar_today - - - {hasReturn ? returnDate : "Retour (optionnel)"} - -
-
-
- {hasReturn ? returnTime : "--:--"} -
-
- - {/* 5. Voyageurs */} -
setShowPassengerModal(true)} - className="bg-[#f2f2f3] rounded-lg px-3.5 py-2.5 mb-4 sm:mb-5 flex items-center justify-between hover:bg-[#eaeaea] transition-colors cursor-pointer" - > -
- - account_circle - - - {passengerCount} Voyageur{passengerCount > 1 ? 's' : ''}, {selectedDiscountCard} - -
- - add - -
- - {/* 6. Rechercher Button (Full width on mobile, right on desktop) */} -
- -
-
+ {/* ─── REUSABLE TIMETABLE SEARCH FORM MODULE (IMAGE 1) ─── */} + setSearchSuccessMessage(`Recherche d'horaire lancée de "${params.departure || 'Gare'}" à "${params.arrival || 'Gare'}" pour le ${params.outwardDate}`)} + /> {/* ─── SIDE COLUMN (INFO TRAFIC + S'ABONNER) ─── */}
@@ -1486,7 +1257,7 @@ export default function RegionContent({ id: Date.now(), title: `Billet ${selectedStation} → ${dep.destName}`, price: 12.00, - date: outwardDate + date: new Date().toLocaleDateString('fr-FR') }]); setShowCartDrawer(true); }} @@ -1690,6 +1461,53 @@ export default function RegionContent({
+ {/* ================================================================== */} + {/* 5.5 ÉVÉNEMENTS RÉGIONAUX (BDD SUPABASE) */} + {/* ================================================================== */} + {evenementsItems.length > 0 && ( +
+
+

+ Événements en {regionDisplayName} +

+
+ {evenementsItems.map((ev, idx) => ( +
+
+ {ev.image_url && ( +
+ {ev.titre} +
+ )} +

{ev.titre}

+ {ev.date_debut && ( +

+ Du {new Date(ev.date_debut).toLocaleDateString('fr-FR')} {ev.date_fin ? `au ${new Date(ev.date_fin).toLocaleDateString('fr-FR')}` : ''} +

+ )} + {ev.description && ( +
+ +
+ )} +
+ {ev.lien_externe && ( + + En savoir plus ↗ + + )} +
+ ))} +
+
+
+ )} + {/* ================================================================== */} {/* 6. DESTINATIONS PLÉBISCITÉES */} {/* ================================================================== */} @@ -1837,60 +1655,6 @@ export default function RegionContent({ {/* 8. MODALES & TIROIRS */} {/* ================================================================== */} - {/* MODAL PASSAGERS */} - {showPassengerModal && ( -
-
-

Voyageurs et Cartes

- -
- -
- - {passengerCount} - -
-
- -
- - -
- -
- -
-
-
- )} - {/* DRAWER PANIER */} {showCartDrawer && (
diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx new file mode 100644 index 0000000..dd7c41b --- /dev/null +++ b/src/components/SiteHeader.tsx @@ -0,0 +1,297 @@ +"use client"; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import HeaderMenu, { MenuItem } from '@/components/HeaderMenu'; +import { getRegionTheme } from '@/lib/regionTheme'; + +interface SiteHeaderProps { + regionSlug?: string; + logoUrl?: string; + brandPrimary?: string; + brandSecondary?: string; + menuStructure?: MenuItem[] | null; + activeTab?: string; + onTabSelect?: (label: string) => void; +} + +const REGION_LOGOS: Record = { + 'bourgogne-franche-comte': 'https://www.ter.sncf.com/assets/medias/bfc/headerLogo.svg', + 'occitanie': 'https://www.ter.sncf.com/assets/medias/occitanie/headerLogo.svg', + 'provence-alpes-cote-azur': 'https://www.ter.sncf.com/assets/medias/paca/headerLogo.svg', + 'sud-provence-alpes-cote-d-azur': 'https://www.ter.sncf.com/assets/medias/paca/headerLogo.svg', + 'bretagne': 'https://www.ter.sncf.com/assets/medias/bretagne/headerLogo.svg', + 'grand-est': 'https://www.ter.sncf.com/assets/medias/ge/headerLogo.svg', + 'normandie': 'https://www.ter.sncf.com/assets/medias/normandie/headerLogo.svg', + 'hauts-de-france': 'https://www.ter.sncf.com/assets/medias/hdf/headerLogo.svg', + 'pays-de-la-loire': 'https://www.ter.sncf.com/assets/medias/aleop/headerLogo.svg', + 'nouvelle-aquitaine': 'https://www.ter.sncf.com/assets/medias/na/headerLogo.svg', + 'centre-val-de-loire': 'https://www.ter.sncf.com/assets/medias/cen/headerLogo.svg', + 'auvergne-rhone-alpes': 'https://www.ter.sncf.com/assets/medias/aura/headerLogo.svg', +}; + +export default function SiteHeader({ + regionSlug = 'bourgogne-franche-comte', + logoUrl, + brandPrimary, + brandSecondary, + menuStructure, + activeTab, + onTabSelect, +}: SiteHeaderProps) { + const theme = getRegionTheme(regionSlug); + const [showOrderModal, setShowOrderModal] = useState(false); + const [showCartDrawer, setShowCartDrawer] = useState(false); + const [showAccountModal, setShowAccountModal] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + const effectiveLogoUrl = logoUrl || REGION_LOGOS[regionSlug] || REGION_LOGOS['bourgogne-franche-comte']; + + return ( +
+ {/* ─── TOP BRANDING & ACTIONS BAR (CONFORME IMG 2) ─── */} +
+ + {/* Left: Mobile Hamburger Icon (Img 2) & Logos */} +
+ + + + {effectiveLogoUrl ? ( + Logo TER Région + ) : ( +
+ + SNCF + + + TER + +
+ )} + + {(brandPrimary || brandSecondary) && ( +
+ + {brandPrimary || "RÉGION"} + + + {brandSecondary || "BOURGOGNE-FRANCHE-COMTÉ"} + +
+ )} + +
+ + {/* Right: Actions (Commande, Panier, Compte - Img 2) */} +
+ + {/* Commande */} + + +
+ + {/* Panier */} + + +
+ + {/* Compte */} + +
+
+ + {/* ─── DYNAMIC HEADER MENU (ATTACHED TO HEADER & STICKY) ─── */} + + + {/* MODALE COMMANDE */} + {showOrderModal && ( +
setShowOrderModal(false)} + > +
e.stopPropagation()} + > +
+

Suivi & Gestion de Commande

+ +
+

+ Saisissez votre numéro de commande pour consulter vos billets et justificatifs de voyage. +

+ +
+ + +
+
+
+ )} + + {/* DRAWER PANIER */} + {showCartDrawer && ( +
setShowCartDrawer(false)} + > +
e.stopPropagation()} + > +
+
+
+ shopping_bag +

Votre Panier

+
+ +
+
+ remove_shopping_cart + Votre panier est vide pour le moment. +
+
+ +
+
+ )} + + {/* MODALE COMPTE */} + {showAccountModal && ( +
setShowAccountModal(false)} + > +
e.stopPropagation()} + > +
+

Espace Mon Compte

+ +
+
+ + +
+ +
+
+ )} + +
+ ); +} diff --git a/src/components/TimetableSearchForm.tsx b/src/components/TimetableSearchForm.tsx new file mode 100644 index 0000000..4479a97 --- /dev/null +++ b/src/components/TimetableSearchForm.tsx @@ -0,0 +1,810 @@ +"use client"; + +import React, { useState, useEffect, useRef } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { supabase } from '@/lib/supabase'; + +type ActiveField = 'departure' | 'arrival' | 'via' | 'outwardDate' | 'outwardTime' | 'returnDate' | 'returnTime' | 'passenger' | null; + +export interface PassengerInfo { + id: number; + age: string; + discountCard: string; +} + +interface TimetableSearchFormProps { + initialDeparture?: string; + initialArrival?: string; + regionSlug?: string; + onSearch?: (searchParams: { + departure: string; + arrival: string; + via: string; + outwardDate: string; + outwardTime: string; + hasReturn: boolean; + returnDate: string; + returnTime: string; + passengerCount: number; + discountCard: string; + }) => void; + className?: string; +} + +const DEFAULT_STATIONS = [ + 'Dijon', + 'Dijon (Toutes Gares)', + 'Dijon Porte Neuve', + 'Neuilly-lès-Dijon', + 'Aiserey', + 'Bourg-en-Bresse', + 'Brazey-en-Plaine', + 'Chaugey', + 'Longecourt', + 'Louhans', + 'Mervans', + 'Ouges', + 'Pagny', + 'Saint-Amour', + 'Saint-Jean-de-Losne', + 'Saulon', + 'Seurre', + 'Paris Gare de Lyon', + 'Paris Montparnasse', + 'Paris Est', + 'Paris Nord', + 'Lyon Part-Dieu', + 'Lyon Perrache', + 'Marseille Saint-Charles', + 'Toulouse Matabiau', + 'Nice Ville', + 'Rennes', + 'Nantes', + 'Bordeaux Saint-Jean', + 'Strasbourg', + 'Lille Flandres' +]; + +const DISCOUNT_CARDS = [ + 'Carte de réduction (optionnel)', + 'Sans carte', + 'Carte Avantage Jeune (12-27 ans)', + 'Carte Avantage Adulte (27-59 ans)', + 'Carte Avantage Senior (60+ ans)', + 'Abonnement Régional Mobigo', + 'Carte Liberté', + 'Famille Nombreuse' +]; + +const TIME_SLOTS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`); + +export default function TimetableSearchForm({ + initialDeparture = '', + initialArrival = '', + regionSlug: propRegionSlug, + onSearch, + className = '', +}: TimetableSearchFormProps) { + const router = useRouter(); + const pathname = usePathname(); + const containerRef = useRef(null); + + // Determine current region slug from props or URL pathname + const currentRegionSlug = propRegionSlug || (() => { + if (!pathname) return ''; + const parts = pathname.split('/').filter(Boolean); + const knownNonRegion = ['horaires', 'offers', 'se-deplacer', 'abonnements']; + if (parts.length > 0 && !knownNonRegion.includes(parts[0])) { + return parts[0]; + } + return ''; + })(); + + // Form states + const [departureStation, setDepartureStation] = useState(initialDeparture); + const [arrivalStation, setArrivalStation] = useState(initialArrival); + const [viaStation, setViaStation] = useState(''); + const [showVia, setShowVia] = useState(false); + const [outwardDate, setOutwardDate] = useState('Dim. 20 sept.'); + const [outwardTime, setOutwardTime] = useState('16:00'); + const [hasReturn, setHasReturn] = useState(false); + const [returnDate, setReturnDate] = useState('Dim. 20 sept.'); + const [returnTime, setReturnTime] = useState('--:--'); + + // Interactive active field panel state + const [activeField, setActiveField] = useState(null); + + // Station database items + const [allStations, setAllStations] = useState(DEFAULT_STATIONS); + + // Calendar state for date picker (default September 20, 2026) + const [calendarDate, setCalendarDate] = useState(new Date(2026, 8, 20)); // Month 8 is Sept + const [selectedDayNumber, setSelectedDayNumber] = useState(20); + + // Passenger state matching Img 1 + const [passengers, setPassengers] = useState([ + { id: 1, age: '', discountCard: 'Carte de réduction (optionnel)' } + ]); + + // Fetch stations from Supabase + useEffect(() => { + const fetchGares = async () => { + try { + const { data } = await supabase.from('gares').select('nom').order('nom'); + if (data && data.length > 0) { + const names = data.map((g: any) => g.nom); + const combined = Array.from(new Set([...DEFAULT_STATIONS, ...names])); + setAllStations(combined); + } + } catch (err) { + console.error('Error fetching gares:', err); + } + }; + fetchGares(); + }, []); + + // Close side panel on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setActiveField(null); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSwapStations = (e: React.MouseEvent) => { + e.stopPropagation(); + const temp = departureStation; + setDepartureStation(arrivalStation); + setArrivalStation(temp); + }; + + const handleAddPassenger = () => { + setPassengers(prev => [ + ...prev, + { id: prev.length + 1, age: '', discountCard: 'Carte de réduction (optionnel)' } + ]); + }; + + const handleRemovePassenger = (id: number) => { + if (passengers.length <= 1) return; + setPassengers(prev => prev.filter(p => p.id !== id).map((p, idx) => ({ ...p, id: idx + 1 }))); + }; + + const handleUpdatePassenger = (id: number, key: 'age' | 'discountCard', val: string) => { + setPassengers(prev => prev.map(p => p.id === id ? { ...p, [key]: val } : p)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setActiveField(null); + + const firstPassenger = passengers[0]; + const discountStr = firstPassenger?.discountCard !== 'Carte de réduction (optionnel)' + ? firstPassenger.discountCard + : 'Sans carte'; + + if (onSearch) { + onSearch({ + departure: departureStation || 'Dijon', + arrival: arrivalStation || 'Seurre', + via: showVia ? viaStation : '', + outwardDate, + outwardTime, + hasReturn, + returnDate, + returnTime, + passengerCount: passengers.length, + discountCard: discountStr, + }); + } + + const queryParams = new URLSearchParams({ + departure: departureStation || 'Dijon', + arrival: arrivalStation || 'Seurre', + via: showVia ? viaStation : '', + outwardDate, + outwardTime, + hasReturn: hasReturn ? '1' : '0', + returnDate: hasReturn ? returnDate : '', + returnTime: hasReturn ? returnTime : '', + passengers: passengers.length.toString(), + passengerAge: firstPassenger?.age || '30', + discountCard: discountStr, + }); + + const targetUrl = currentRegionSlug + ? `/${currentRegionSlug}/offers?${queryParams.toString()}` + : `/offers?${queryParams.toString()}`; + + router.push(targetUrl); + }; + + // Get matching stations for active text input + const getActiveInputValue = () => { + if (activeField === 'departure') return departureStation; + if (activeField === 'arrival') return arrivalStation; + if (activeField === 'via') return viaStation; + return ''; + }; + + const currentQuery = getActiveInputValue().trim(); + + const filteredStations = allStations.filter(st => + st.toLowerCase().includes(currentQuery.toLowerCase()) + ); + + const handleSelectStation = (stName: string) => { + if (activeField === 'departure') setDepartureStation(stName); + if (activeField === 'arrival') setArrivalStation(stName); + if (activeField === 'via') setViaStation(stName); + setActiveField(null); + }; + + // Calendar calculations + const year = calendarDate.getFullYear(); + const month = calendarDate.getMonth(); + const monthName = calendarDate.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const firstDayOfWeek = (new Date(year, month, 1).getDay() + 6) % 7; // Mon = 0 + + const handlePrevMonth = () => { + setCalendarDate(new Date(year, month - 1, 1)); + }; + + const handleNextMonth = () => { + setCalendarDate(new Date(year, month + 1, 1)); + }; + + const handleSelectDay = (dayNum: number) => { + setSelectedDayNumber(dayNum); + const d = new Date(year, month, dayNum); + const daysShort = ['Dim.', 'Lun.', 'Mar.', 'Mer.', 'Jeu.', 'Ven.', 'Sam.']; + const monthsShort = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']; + const formatted = `${daysShort[d.getDay()]} ${dayNum} ${monthsShort[d.getMonth()]}`; + + if (activeField === 'outwardDate') { + setOutwardDate(formatted); + } else if (activeField === 'returnDate') { + setReturnDate(formatted); + setHasReturn(true); + } + setActiveField(null); + }; + + const handleSelectTime = (slot: string) => { + if (activeField === 'outwardTime') { + setOutwardTime(slot); + } else if (activeField === 'returnTime') { + setReturnTime(slot); + setHasReturn(true); + } + setActiveField(null); + }; + + // Pointer position top offset calculation matching the input row centers (Img 2 & Img 1) + const getPointerTop = () => { + switch (activeField) { + case 'departure': return 'top-6'; + case 'arrival': return 'top-20'; + case 'via': return 'top-28'; + case 'outwardDate': + case 'outwardTime': return 'top-40'; + case 'returnDate': + case 'returnTime': return 'top-56'; + case 'passenger': return 'top-[290px]'; + default: return 'top-6'; + } + }; + + return ( +
+ + {/* ================================================================== */} + {/* 1. LEFT SEARCH FORM CARD (CONFORME IMG 1 & 2) */} + {/* ================================================================== */} +
+
+ + {/* 1. Gare de départ */} +
setActiveField('departure')} + className={`rounded-xl px-4 py-3 flex items-center justify-between transition-all cursor-pointer ${ + activeField === 'departure' + ? 'bg-[#fff8f8] border-2 border-dashed border-[#c00] shadow-xs' + : 'bg-[#f2f3f5] border border-transparent hover:bg-[#eaecef]' + }`} + > +
+ + place + + { + setDepartureStation(e.target.value); + if (activeField !== 'departure') setActiveField('departure'); + }} + onFocus={() => setActiveField('departure')} + placeholder="Gare de départ" + className="w-full bg-transparent text-[14px] text-gray-800 placeholder-gray-500 outline-none font-medium" + /> +
+ +
+ + {/* 2. Gare d'arrivée */} +
setActiveField('arrival')} + className={`rounded-xl px-4 py-3 flex items-center gap-3 transition-all cursor-pointer ${ + activeField === 'arrival' + ? 'bg-[#fff8f8] border-2 border-dashed border-[#c00] shadow-xs' + : 'bg-[#f2f3f5] border border-transparent hover:bg-[#eaecef]' + }`} + > + + place + + { + setArrivalStation(e.target.value); + if (activeField !== 'arrival') setActiveField('arrival'); + }} + onFocus={() => setActiveField('arrival')} + placeholder="Gare d'arrivée" + className="w-full bg-transparent text-[14px] text-gray-800 placeholder-gray-500 outline-none font-medium" + /> +
+ + {/* + Via link */} +
+ +
+ + {showVia && ( +
setActiveField('via')} + className={`rounded-xl px-4 py-2.5 flex items-center gap-2.5 transition-all cursor-pointer ${ + activeField === 'via' + ? 'bg-[#fff8f8] border-2 border-dashed border-[#c00]' + : 'bg-[#f2f3f5] hover:bg-[#eaecef]' + }`} + > + alt_route + setViaStation(e.target.value)} + onFocus={() => setActiveField('via')} + placeholder="Gare de passage (Via)" + className="w-full bg-transparent text-[13.5px] text-gray-800 placeholder-gray-500 outline-none" + /> +
+ )} + + {/* 3. Aller */} +
+
setActiveField('outwardDate')} + className="flex items-center gap-3 flex-1 cursor-pointer" + > + + event + +
+ Aller + + {outwardDate} + +
+
+ +
+ +
setActiveField('outwardTime')} + className="w-14 text-left font-normal text-[14px] text-gray-900 cursor-pointer hover:text-[#006837]" + > + {outwardTime} +
+
+ + {/* 4. Retour */} +
+
{ + setHasReturn(true); + setActiveField('returnDate'); + }} + className="flex items-center gap-3 flex-1 cursor-pointer" + > + + event + + + {hasReturn ? returnDate : "Retour (optionnel)"} + +
+ +
+ +
+
{ + setHasReturn(true); + setActiveField('returnTime'); + }} + className="w-14 text-left font-normal text-[13.5px] text-gray-700 cursor-pointer hover:text-[#006837]" + > + {hasReturn ? returnTime : "--:--"} +
+ + {hasReturn && ( + + )} +
+
+ + {/* 5. Voyageurs (CONFORME IMG 1) */} +
setActiveField('passenger')} + className={`rounded-xl px-4 py-3 flex items-center justify-between transition-all cursor-pointer ${ + activeField === 'passenger' + ? 'bg-[#f7faef] border-2 border-dashed border-[#006837] shadow-xs' + : 'bg-[#f2f3f5] border border-transparent hover:bg-[#eaecef]' + }`} + > +
+ + person + + + {passengers.length} Voyageur{passengers.length > 1 ? 's' : ''} + {passengers[0]?.age ? `, ${passengers[0].age} ans` : ''} + {passengers[0]?.discountCard && passengers[0].discountCard !== 'Carte de réduction (optionnel)' ? `, ${passengers[0].discountCard}` : ', sans carte'} + +
+ + add + +
+ + {/* 6. Action Button */} +
+ +
+ +
+
+ + {/* ================================================================== */} + {/* 2. DYNAMIC SIDE PANEL CARD (CONFORME IMG 1, 2, 3, 4, 5) */} + {/* ================================================================== */} + {activeField && ( +
+ {/* Left Pointer Arrow (Speech Bubble Triangle) matching Img 1 & 2 */} +
+ + {/* ─── CASE A: STATION SELECTION (GARE DE DÉPART / ARRIVÉE / VIA) (IMG 2 & 3) ─── */} + {(activeField === 'departure' || activeField === 'arrival' || activeField === 'via') && ( +
+

+ {activeField === 'departure' ? "Gare de départ" : activeField === 'arrival' ? "Gare d'arrivée" : "Gare de passage"} +

+ + {currentQuery.length < 2 ? ( +
+

+ Veuillez saisir au moins 2 caractères dans ce champ. +

+
+
+ ) : ( +
+

+ Veuillez sélectionner une gare dans cette liste. +

+
+ +
+ {filteredStations.length > 0 ? ( + filteredStations.map((st, idx) => { + const isFirst = idx === 0; + return ( +
handleSelectStation(st)} + className={`transition-all cursor-pointer ${ + isFirst + ? 'bg-[#e6f0d7] border border-dashed border-[#006837] rounded-xl p-3 text-[15px] font-medium text-gray-900 shadow-2xs' + : 'py-2.5 px-2 border-b border-[#006837] text-[15px] text-gray-900 hover:bg-[#f4f8ec] transition-colors font-medium' + }`} + > + {st} +
+ ); + }) + ) : ( +
+ Aucune gare ne correspond à "{currentQuery}" +
+ )} +
+
+ )} +
+ )} + + {/* ─── CASE B: DATE PICKER (ALLER / RETOUR) (IMG 4) ─── */} + {(activeField === 'outwardDate' || activeField === 'returnDate') && ( +
+

+ {activeField === 'outwardDate' ? "Date de départ Aller" : "Date de départ Retour"} +

+ +

+ Format attendu : "Jour/Mois/Année complète" (exemple: 19/09/2026). +

+ + {/* Month navigation */} +
+ + {monthName} + +
+ + +
+
+ + {/* Calendar Grid */} +
+ {['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => ( + + {d} + + ))} +
+ +
+ {/* Blank days for month start alignment */} + {Array.from({ length: firstDayOfWeek }).map((_, i) => ( +
+ ))} + + {/* Days of month */} + {Array.from({ length: daysInMonth }, (_, i) => i + 1).map((dayNum) => { + const isSelected = dayNum === selectedDayNumber; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const dayDate = new Date(year, month, dayNum, 0, 0, 0, 0); + const isPastDate = dayDate < today; + + return ( + + ); + })} +
+
+ )} + + {/* ─── CASE C: TIME PICKER (ALLER / RETOUR) (IMG 5) ─── */} + {(activeField === 'outwardTime' || activeField === 'returnTime') && ( +
+

+ {activeField === 'outwardTime' ? "Heure de départ" : "Heure de retour"} +

+ +
+ {TIME_SLOTS.map((slot) => { + const currentSelected = activeField === 'outwardTime' ? outwardTime : returnTime; + const isSelected = slot === currentSelected; + + const now = new Date(); + const isTodaySelected = (year === now.getFullYear() && month === now.getMonth() && selectedDayNumber === now.getDate()); + const slotHour = parseInt(slot.split(':')[0], 10); + const isPastTime = isTodaySelected && slotHour < now.getHours(); + + return ( +
!isPastTime && handleSelectTime(slot)} + className={`py-2.5 px-4 text-[15px] transition-colors ${ + isPastTime + ? 'text-gray-300 opacity-40 cursor-not-allowed bg-gray-50 line-through border-b border-gray-100' + : isSelected + ? 'bg-[#e5ecc5] font-semibold text-gray-900 cursor-pointer' + : 'border-b border-[#006837] text-gray-900 hover:bg-[#f4f8ec] cursor-pointer' + }`} + title={isPastTime ? 'Heure passée' : undefined} + > + {slot} +
+ ); + })} +
+
+ )} + + {/* ─── CASE D: VOYAGEUR VOLET (STRICTLY CONFORME IMG 1) ─── */} + {activeField === 'passenger' && ( +
+

+ Voyageur +

+ +
+ {passengers.map((p, index) => ( +
+
+
+ + person + + + Voyageur {index + 1} + +
+ {passengers.length > 1 && ( + + )} +
+ + {/* Field 1: Âge (optionnel) - Box with dashed green border matching Image 1 */} +
+ +
+ handleUpdatePassenger(p.id, 'age', e.target.value)} + placeholder="Ex: 30" + className="w-full bg-transparent text-[14px] font-semibold text-gray-900 outline-none" + /> +
+ unfold_more +
+
+
+ + {/* Field 2: Carte de réduction (optionnel) - Select matching Image 1 */} +
+ +
+
+ ))} +
+ + {/* Yellow Horizontal Divider Line (CONFORME IMG 1) */} +
+ + {/* + Ajouter un voyageur Green Button (CONFORME IMG 1) */} +
+ +
+ +
+ )} + +
+ )} + +
+ ); +} + diff --git a/src/lib/perturbationUtils.ts b/src/lib/perturbationUtils.ts new file mode 100644 index 0000000..1c021a2 --- /dev/null +++ b/src/lib/perturbationUtils.ts @@ -0,0 +1,113 @@ +/** + * Utility functions for perturbation date matching & UI banner styling (matching Image 1 & Image 2) + */ + +export interface PerturbationData { + id?: string | number; + horaire_id?: string | number; + type_perturbation: 'retard' | 'suppression' | 'information' | 'parcours_modifie' | string; + duree_retard_minutes?: number; + cause?: string; + cause_details?: string; + date_debut?: string; + date_fin?: string; + jours_perturbation?: any[]; + actif?: boolean; +} + +/** + * Checks if a perturbation strictly applies to the recorded target ISO date (YYYY-MM-DD) + */ +export function isPerturbationActiveOnDate(pert: any, targetDateIso: string): boolean { + if (!pert || pert.actif === false) return false; + + // 1. Check jours_perturbation array if present + if (Array.isArray(pert.jours_perturbation) && pert.jours_perturbation.length > 0) { + return pert.jours_perturbation.some((jp: any) => { + if (typeof jp === 'string') { + return jp.slice(0, 10) === targetDateIso; + } + if (jp && typeof jp === 'object') { + const d = (jp.date || jp.dateDebut || jp.jour || '').slice(0, 10); + return d === targetDateIso; + } + return false; + }); + } + + // 2. Check date_debut and date_fin + const start = pert.date_debut ? pert.date_debut.slice(0, 10) : null; + const end = pert.date_fin ? pert.date_fin.slice(0, 10) : null; + + if (start && end) { + return targetDateIso >= start && targetDateIso <= end; + } + if (start) { + return targetDateIso === start; + } + if (end) { + return targetDateIso === end; + } + + // Strictly assign perturbations only when target date matches recorded date + return false; +} + +export interface BannerStyle { + bgColor: string; // Background color matching Image 2 + textColor: string; // Text color + iconBgColor: string; // Icon circle bg color + iconText: string; // Icon character ('i', '!', '✕', '↔') + badgeLabel: string; // Label ('Information', 'Informations', 'Retard', 'Suppression', 'Parcours modifié') + defaultCause?: string; +} + +/** + * Gets banner styling and metadata strictly matching Image 2 + */ +export function getPerturbationBannerStyle(type: string): BannerStyle { + const normType = (type || '').toLowerCase(); + + if (normType.includes('retard')) { + return { + bgColor: '#fff1ed', + textColor: '#c00', + iconBgColor: '#e65100', + iconText: '!', + badgeLabel: 'Retard', + defaultCause: 'Retard estimé' + }; + } + + if (normType.includes('suppr')) { + return { + bgColor: '#fdeded', + textColor: '#c00', + iconBgColor: '#c00', + iconText: '✕', + badgeLabel: 'Suppression', + defaultCause: 'Train supprimé' + }; + } + + if (normType.includes('parcours') || normType.includes('modifi')) { + return { + bgColor: '#f0f7ff', + textColor: '#0066a4', + iconBgColor: '#0066a4', + iconText: '↔', + badgeLabel: 'Parcours modifié', + defaultCause: 'Modification de parcours' + }; + } + + // Default: Information + return { + bgColor: '#eef7fc', + textColor: '#0066a4', + iconBgColor: '#0066a4', + iconText: 'i', + badgeLabel: 'Informations', + defaultCause: 'Informations voyageurs' + }; +} diff --git a/src/lib/regionTheme.ts b/src/lib/regionTheme.ts new file mode 100644 index 0000000..249ba4d --- /dev/null +++ b/src/lib/regionTheme.ts @@ -0,0 +1,99 @@ +export interface RegionTheme { + primary: string; + primaryHover: string; + primaryLight: string; + secondary: string; + badgeText: string; +} + +export const REGION_THEMES: Record = { + 'bourgogne-franche-comte': { + primary: '#006837', + primaryHover: '#00522b', + primaryLight: '#eef5e6', + secondary: '#8da300', + badgeText: 'TER Bourgogne-Franche-Comté', + }, + 'occitanie': { + primary: '#c0001a', + primaryHover: '#990014', + primaryLight: '#fde8ea', + secondary: '#e60000', + badgeText: 'liO Train Occitanie', + }, + 'provence-alpes-cote-azur': { + primary: '#005088', + primaryHover: '#003a63', + primaryLight: '#e5f0f8', + secondary: '#009fe3', + badgeText: 'ZOU ! TER PACA', + }, + 'sud-provence-alpes-cote-d-azur': { + primary: '#005088', + primaryHover: '#003a63', + primaryLight: '#e5f0f8', + secondary: '#009fe3', + badgeText: 'ZOU ! TER PACA', + }, + 'bretagne': { + primary: '#0077b6', + primaryHover: '#005b8c', + primaryLight: '#e6f4fa', + secondary: '#0096c7', + badgeText: 'TER BreizhGo', + }, + 'grand-est': { + primary: '#2b5c8f', + primaryHover: '#1e4267', + primaryLight: '#eaf0f6', + secondary: '#4885c1', + badgeText: 'TER Fluo Grand Est', + }, + 'normandie': { + primary: '#d32f2f', + primaryHover: '#b71c1c', + primaryLight: '#fbe9e7', + secondary: '#e53935', + badgeText: 'TER Nomad Normandie', + }, + 'hauts-de-france': { + primary: '#004b87', + primaryHover: '#003663', + primaryLight: '#e5edf5', + secondary: '#0066b3', + badgeText: 'TER Hauts-de-France', + }, + 'pays-de-la-loire': { + primary: '#008459', + primaryHover: '#006343', + primaryLight: '#e6f5ef', + secondary: '#00a872', + badgeText: 'TER Aléop', + }, + 'nouvelle-aquitaine': { + primary: '#0066a4', + primaryHover: '#004c7a', + primaryLight: '#e6f2fa', + secondary: '#0085d0', + badgeText: 'TER Nouvelle-Aquitaine', + }, + 'centre-val-de-loire': { + primary: '#008080', + primaryHover: '#006666', + primaryLight: '#e6f2f2', + secondary: '#00a3a3', + badgeText: 'TER Rémi', + }, + 'auvergne-rhone-alpes': { + primary: '#00609c', + primaryHover: '#004875', + primaryLight: '#e6f1f8', + secondary: '#007bc7', + badgeText: 'TER Auvergne-Rhône-Alpes', + }, +}; + +export function getRegionTheme(slug: string = 'bourgogne-franche-comte'): RegionTheme { + const cleanSlug = (slug || 'bourgogne-franche-comte').toLowerCase(); + return REGION_THEMES[cleanSlug] || REGION_THEMES['bourgogne-franche-comte']; +}