Implémentation des page :
-> Prochains départs / Arrivées (beta) -> Recherches d'horaires (actif)
This commit is contained in:
1 parent
9b7ff6e295
commit
24cb502770
18 files changed
+5495
-465
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -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 (
|
||||||
|
<HorairesPageClient
|
||||||
|
regionSlug={region}
|
||||||
|
regionData={regionData}
|
||||||
|
menuStructure={menuStructure}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#f8f9fa] font-sans text-gray-500">
|
||||||
|
Chargement des offres de billets...
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<OffersPageClient
|
||||||
|
regionSlug={region}
|
||||||
|
menuStructure={menuStructure}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
+49
-14
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import RegionContent, { InfoTraficItem, ActualiteItem, HoraireItem } from '@/components/RegionContent';
|
import RegionContent, { InfoTraficItem, ActualiteItem, EvenementItem, HoraireItem } from '@/components/RegionContent';
|
||||||
|
|
||||||
interface RegionData {
|
interface RegionData {
|
||||||
nom_site: string;
|
nom_site: string;
|
||||||
@@ -18,6 +18,13 @@ export default async function RegionPage({
|
|||||||
}) {
|
}) {
|
||||||
const { region } = await params;
|
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
|
// 1. Fetch general settings
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('parametres')
|
.from('parametres')
|
||||||
@@ -33,15 +40,7 @@ export default async function RegionPage({
|
|||||||
|
|
||||||
if (data && data.valeur) {
|
if (data && data.valeur) {
|
||||||
const val = data.valeur as any;
|
const val = data.valeur as any;
|
||||||
|
let matchedRegionKey = regionCandidates.find(k => val.regions && val.regions[k]) || regionCandidates[0];
|
||||||
// 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];
|
|
||||||
|
|
||||||
const rObj = val.regions?.[matchedRegionKey] || {};
|
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[] = [];
|
let infoTraficList: InfoTraficItem[] = [];
|
||||||
try {
|
try {
|
||||||
const { data: trafficData } = await supabase
|
const { data: trafficData } = await supabase
|
||||||
.from('info_trafic')
|
.from('info_trafic')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('actif', true)
|
.eq('actif', true)
|
||||||
|
.in('region_id', regionCandidates)
|
||||||
.is('deleted_at', null)
|
.is('deleted_at', null)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
if (trafficData) {
|
if (trafficData) {
|
||||||
@@ -82,13 +82,14 @@ export default async function RegionPage({
|
|||||||
console.error('Error fetching info_trafic:', e);
|
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[] = [];
|
let actualitesList: ActualiteItem[] = [];
|
||||||
try {
|
try {
|
||||||
const { data: actusData } = await supabase
|
const { data: actusData } = await supabase
|
||||||
.from('actualites')
|
.from('actualites')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('actif', true)
|
.eq('actif', true)
|
||||||
|
.in('region_id', regionCandidates)
|
||||||
.is('deleted_at', null)
|
.is('deleted_at', null)
|
||||||
.order('date_publication', { ascending: false })
|
.order('date_publication', { ascending: false })
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
@@ -99,7 +100,40 @@ export default async function RegionPage({
|
|||||||
console.error('Error fetching actualites:', e);
|
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[] = [];
|
let horairesList: HoraireItem[] = [];
|
||||||
try {
|
try {
|
||||||
const { data: garesData } = await supabase
|
const { data: garesData } = await supabase
|
||||||
@@ -137,8 +171,9 @@ export default async function RegionPage({
|
|||||||
regionData={regionData}
|
regionData={regionData}
|
||||||
initialInfoTrafic={infoTraficList}
|
initialInfoTrafic={infoTraficList}
|
||||||
initialActualites={actualitesList}
|
initialActualites={actualitesList}
|
||||||
|
initialEvenements={evenementsList}
|
||||||
|
initialMenuStructure={menuStructure}
|
||||||
initialHoraires={horairesList}
|
initialHoraires={horairesList}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 <RegionHorairesPage params={params} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#f8f9fa] font-sans text-gray-500">
|
||||||
|
Chargement des prochains départs...
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<ProchainsDepartsClient
|
||||||
|
regionSlug={region}
|
||||||
|
menuStructure={menuStructure}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<HorairesPageClient
|
||||||
|
regionSlug={targetRegion}
|
||||||
|
regionData={regionData}
|
||||||
|
menuStructure={menuStructure}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#f8f9fa] font-sans text-gray-500">
|
||||||
|
Chargement des offres de billets...
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<OffersPageClient
|
||||||
|
regionSlug="bourgogne-franche-comte"
|
||||||
|
menuStructure={menuStructure}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-8">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="w-10 h-10 border-4 border-[#006837] border-t-transparent rounded-full animate-spin mx-auto"></div>
|
||||||
|
<p className="text-sm font-medium text-gray-700">Chargement des prochains départs...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<ProchainsDepartsClient
|
||||||
|
regionSlug="bourgogne-franche-comte"
|
||||||
|
menuStructure={menuStructure}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<number | null>(null);
|
||||||
|
const [internalMobileMenuOpen, setInternalMobileMenuOpen] = useState<boolean>(false);
|
||||||
|
const [expandedMobileIndices, setExpandedMobileIndices] = useState<Record<number, boolean>>({});
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<MenuItem[]>(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 (
|
||||||
|
<div className="relative z-40 bg-white border-b border-gray-200 shadow-xs">
|
||||||
|
{/* DESKTOP NAVIGATION BAR (IMAGE 1 & 2) */}
|
||||||
|
<nav className="hidden lg:block">
|
||||||
|
<div className="max-w-[1340px] mx-auto px-6 flex items-center gap-7 text-[13px] font-medium text-gray-700">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isHovered = hoveredIndex === index;
|
||||||
|
|
||||||
|
const itemContainsHoraires = (it: MenuItem): boolean => {
|
||||||
|
if (it.url && it.url.includes('/horaires')) return true;
|
||||||
|
if (it.sous_elements && Array.isArray(it.sous_elements)) {
|
||||||
|
return it.sous_elements.some(sub => (sub.url && sub.url.includes('/horaires')) || sub.label.toLowerCase().includes('horaire'));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isHorairesPage = pathname ? pathname.includes('/horaires') : false;
|
||||||
|
const isActive = isHorairesPage && itemContainsHoraires(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id || index}
|
||||||
|
className="relative py-3.5"
|
||||||
|
onMouseEnter={() => handleMouseEnter(index)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={resolveUrl(item.url)}
|
||||||
|
onClick={() => {
|
||||||
|
if (onTabSelect) onTabSelect(item.label);
|
||||||
|
}}
|
||||||
|
className={`inline-block transition-colors cursor-pointer border-b-[3px] -mb-[3.5px] pb-3 whitespace-nowrap ${
|
||||||
|
isActive
|
||||||
|
? 'font-bold border-[#8da300] text-gray-900'
|
||||||
|
: isHovered
|
||||||
|
? 'border-[#d1d800] text-gray-900'
|
||||||
|
: 'border-transparent text-gray-700 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
style={
|
||||||
|
isActive
|
||||||
|
? { borderColor: '#8da300', color: '#111111' }
|
||||||
|
: isHovered
|
||||||
|
? { borderColor: '#d1d800' }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DESKTOP MEGA DROPDOWN (ORGANISÉ EN COLONNES) */}
|
||||||
|
{activeItem && activeSubItems.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 top-full bg-white shadow-xl border-t border-gray-100 z-50 animate-fadeIn"
|
||||||
|
style={{ borderBottomWidth: '4px', borderBottomColor: theme.secondary || '#d1d800' }}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
}}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<div className="max-w-[1340px] mx-auto px-6 py-6">
|
||||||
|
{/* Disposition par colonnes verticales */}
|
||||||
|
<div className="grid grid-flow-col auto-cols-fr gap-8 items-start">
|
||||||
|
{activeSubItems.map((sub, sIdx) => {
|
||||||
|
const nestedList = getSubItems(sub);
|
||||||
|
return (
|
||||||
|
<div key={sub.id || sIdx} className="flex flex-col space-y-3 min-w-[200px]">
|
||||||
|
<Link
|
||||||
|
href={resolveUrl(sub.url)}
|
||||||
|
className="text-[14px] font-semibold text-gray-900 transition-colors block border-b pb-1.5"
|
||||||
|
style={{ borderColor: `${theme.secondary}40`, color: theme.primary }}
|
||||||
|
>
|
||||||
|
{sub.label}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{nestedList.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="pl-3 space-y-2 mt-1 flex flex-col border-l-2"
|
||||||
|
style={{ borderColor: theme.secondary || '#d1d800' }}
|
||||||
|
>
|
||||||
|
{nestedList.map((nested, nIdx) => (
|
||||||
|
<Link
|
||||||
|
key={nested.id || nIdx}
|
||||||
|
href={resolveUrl(nested.url)}
|
||||||
|
className="text-[12.5px] text-gray-700 hover:text-black block transition-colors leading-relaxed font-normal"
|
||||||
|
>
|
||||||
|
{nested.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* MOBILE BAR & ACCORDION DRAWER */}
|
||||||
|
<div className="lg:hidden flex items-center justify-between px-4 py-2.5 bg-gray-50 border-t border-gray-200">
|
||||||
|
<span className="text-[13px] font-semibold text-gray-800">Menu Régional</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white border border-gray-300 text-gray-700 text-xs font-medium cursor-pointer shadow-xs active:bg-gray-100"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[18px]">
|
||||||
|
{isMobileMenuOpen ? 'close' : 'menu'}
|
||||||
|
</span>
|
||||||
|
<span>{isMobileMenuOpen ? 'Fermer' : 'Navigation'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<div className="lg:hidden bg-white border-b border-gray-200 px-4 py-3 space-y-2 animate-fadeIn">
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
const subItems = getSubItems(item);
|
||||||
|
const isExpanded = !!expandedMobileIndices[idx];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id || idx} className="border-b border-gray-100 pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href={resolveUrl(item.url)}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="text-[14px] font-semibold text-gray-800 hover:text-[#006837] py-1"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{subItems.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMobileIndex(idx)}
|
||||||
|
className="p-1 text-gray-500 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[20px]">
|
||||||
|
{isExpanded ? 'expand_less' : 'expand_more'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && subItems.length > 0 && (
|
||||||
|
<div className="pl-4 mt-2 space-y-2 border-l-2 border-[#d1d800]">
|
||||||
|
{subItems.map((sub, sIdx) => {
|
||||||
|
const nestedList = getSubItems(sub);
|
||||||
|
return (
|
||||||
|
<div key={sub.id || sIdx} className="space-y-1">
|
||||||
|
<Link
|
||||||
|
href={resolveUrl(sub.url)}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="text-[13px] font-medium text-gray-700 hover:text-[#006837] block py-0.5"
|
||||||
|
>
|
||||||
|
{sub.label}
|
||||||
|
</Link>
|
||||||
|
{nestedList.length > 0 && (
|
||||||
|
<div className="pl-3 space-y-1 border-l border-gray-200">
|
||||||
|
{nestedList.map((nested, nIdx) => (
|
||||||
|
<Link
|
||||||
|
key={nested.id || nIdx}
|
||||||
|
href={resolveUrl(nested.url)}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="text-[12px] text-gray-500 hover:text-gray-800 block py-0.5"
|
||||||
|
>
|
||||||
|
{nested.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, { heroImage: string; logoUrl: string; name: string }> = {
|
||||||
|
'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<string | null>(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 (
|
||||||
|
<ClientWcsProvider>
|
||||||
|
<div className="w-full bg-white min-h-screen text-gray-800 font-sans">
|
||||||
|
|
||||||
|
{/* ================================================================== */}
|
||||||
|
{/* 1. STICKY HEADER COMPONENT WITH REGIONAL LOGO AND MENU */}
|
||||||
|
{/* ================================================================== */}
|
||||||
|
<SiteHeader
|
||||||
|
regionSlug={regionSlug}
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
menuStructure={menuStructure}
|
||||||
|
activeTab="Se déplacer"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ================================================================== */}
|
||||||
|
{/* 3. BREADCRUMB BAR BELOW MENU (IMAGE 1) */}
|
||||||
|
{/* ================================================================== */}
|
||||||
|
<div className="bg-white text-xs py-2.5 px-4 sm:px-6">
|
||||||
|
<div className="max-w-[1340px] mx-auto flex items-center gap-2 text-gray-600">
|
||||||
|
<Link href={`/${regionSlug}`} className="hover:text-black flex items-center gap-1">
|
||||||
|
<span className="material-icons text-[17px] text-[#006837]">home</span>
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-400">></span>
|
||||||
|
<span className="font-medium text-gray-800">Rechercher un horaire</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ================================================================== */}
|
||||||
|
{/* 4. HERO SECTION WITH SEARCH CARD ON THE LEFT (IMAGE 1) */}
|
||||||
|
{/* ================================================================== */}
|
||||||
|
<section
|
||||||
|
className="relative w-full bg-cover bg-center bg-no-repeat min-h-[500px] sm:min-h-[560px] py-6 sm:py-10 px-4 sm:px-6"
|
||||||
|
style={{ backgroundImage: `url(${heroImage})` }}
|
||||||
|
>
|
||||||
|
<div className="max-w-[1340px] mx-auto relative z-10">
|
||||||
|
{searchFeedback && (
|
||||||
|
<div className="mb-4 bg-white/95 border-l-4 border-[#006837] text-gray-900 px-4 py-3 rounded-lg shadow-md flex justify-between items-center text-sm font-medium">
|
||||||
|
<span>{searchFeedback}</span>
|
||||||
|
<button onClick={() => setSearchFeedback(null)} className="text-gray-500 hover:text-black font-bold">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timetable Search Form card pinned to LEFT side (Image 1) */}
|
||||||
|
<div className="w-full lg:w-[440px]">
|
||||||
|
<TimetableSearchForm onSearch={handleSearchResults} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ================================================================== */}
|
||||||
|
{/* 5. FOOTER */}
|
||||||
|
{/* ================================================================== */}
|
||||||
|
<footer className="bg-white border-t border-gray-200 py-8 px-4 sm:px-6 text-center text-xs text-gray-500 space-y-4">
|
||||||
|
<div className="flex flex-wrap justify-center gap-4 text-gray-600 font-medium">
|
||||||
|
<Link href={`/${regionSlug}`} className="hover:underline">Accueil {cfg.name}</Link>
|
||||||
|
<span>•</span>
|
||||||
|
<Link href={`/${regionSlug}/horaires`} className="hover:underline">Rechercher un horaire</Link>
|
||||||
|
<span>•</span>
|
||||||
|
<Link href={`/${regionSlug}#info-trafic`} className="hover:underline">Info Trafic</Link>
|
||||||
|
<span>•</span>
|
||||||
|
<Link href={`/${regionSlug}#actualites`} className="hover:underline">Actualités</Link>
|
||||||
|
</div>
|
||||||
|
<p>© FERROVIA - Tous droits réservés.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</ClientWcsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+215
-451
@@ -1,8 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { supabase } from '@/lib/supabase';
|
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 {
|
interface RegionData {
|
||||||
nom_site: string;
|
nom_site: string;
|
||||||
@@ -39,6 +44,21 @@ export interface ActualiteItem {
|
|||||||
region_id?: string;
|
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 {
|
export interface HoraireGareDesservie {
|
||||||
gare_id?: string;
|
gare_id?: string;
|
||||||
gare_nom: string;
|
gare_nom: string;
|
||||||
@@ -492,16 +512,27 @@ export default function RegionContent({
|
|||||||
regionData,
|
regionData,
|
||||||
initialInfoTrafic = [],
|
initialInfoTrafic = [],
|
||||||
initialActualites = [],
|
initialActualites = [],
|
||||||
|
initialEvenements = [],
|
||||||
|
initialMenuStructure = null,
|
||||||
initialHoraires = []
|
initialHoraires = []
|
||||||
}: {
|
}: {
|
||||||
regionSlug: string;
|
regionSlug: string;
|
||||||
regionData: RegionData;
|
regionData: RegionData;
|
||||||
initialInfoTrafic?: InfoTraficItem[];
|
initialInfoTrafic?: InfoTraficItem[];
|
||||||
initialActualites?: ActualiteItem[];
|
initialActualites?: ActualiteItem[];
|
||||||
|
initialEvenements?: EvenementItem[];
|
||||||
|
initialMenuStructure?: any;
|
||||||
initialHoraires?: HoraireItem[];
|
initialHoraires?: HoraireItem[];
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
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
|
// Dynamic Region Configuration
|
||||||
const config = REGION_CONFIGS[regionSlug] || REGION_CONFIGS["sud-provence-alpes-cote-d-azur"];
|
const config = REGION_CONFIGS[regionSlug] || REGION_CONFIGS["sud-provence-alpes-cote-d-azur"];
|
||||||
const regionDisplayName = regionData?.nom_site
|
const regionDisplayName = regionData?.nom_site
|
||||||
@@ -509,28 +540,16 @@ export default function RegionContent({
|
|||||||
: config.displayName;
|
: config.displayName;
|
||||||
|
|
||||||
// State: Navigation tabs
|
// State: Navigation tabs
|
||||||
const [activeTab, setActiveTab] = useState("Tarifs et cartes de réduction");
|
const [activeTab, setActiveTab] = useState("Se déplacer");
|
||||||
|
|
||||||
// State: Search Booking Box
|
// State: Search feedback
|
||||||
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);
|
|
||||||
const [searchSuccessMessage, setSearchSuccessMessage] = useState("");
|
const [searchSuccessMessage, setSearchSuccessMessage] = useState("");
|
||||||
|
|
||||||
// State: Prochains départs
|
// State: Prochains départs
|
||||||
const [selectedStation, setSelectedStation] = useState(config.stations[0] || "Dijon");
|
const [selectedStation, setSelectedStation] = useState(config.stations[0] || "Dijon");
|
||||||
const [expandedDepartures, setExpandedDepartures] = useState(false);
|
const [expandedDepartures, setExpandedDepartures] = useState(false);
|
||||||
|
|
||||||
// State: Traffic info (Default to collapsed/non-extended mode)
|
// State: Traffic info
|
||||||
const [trafficPage, setTrafficPage] = useState(1);
|
const [trafficPage, setTrafficPage] = useState(1);
|
||||||
const [isTrafficExpanded, setIsTrafficExpanded] = useState(false);
|
const [isTrafficExpanded, setIsTrafficExpanded] = useState(false);
|
||||||
|
|
||||||
@@ -556,10 +575,13 @@ export default function RegionContent({
|
|||||||
}
|
}
|
||||||
}, [regionSlug, config.stations]);
|
}, [regionSlug, config.stations]);
|
||||||
|
|
||||||
// 1. STATE & LIVE SUPABASE SYNC (ZERO MOCK)
|
// 1. STATE & LIVE SUPABASE SYNC (STRICTLY REGIONAL)
|
||||||
const [trafficDataList, setTrafficDataList] = useState<InfoTraficItem[]>(initialInfoTrafic || []);
|
const [trafficDataList, setTrafficDataList] = useState<InfoTraficItem[]>(initialInfoTrafic || []);
|
||||||
const [actusDataList, setActusDataList] = useState<ActualiteItem[]>(initialActualites || []);
|
const [actusDataList, setActusDataList] = useState<ActualiteItem[]>(initialActualites || []);
|
||||||
|
const [evenementsDataList, setEvenementsDataList] = useState<EvenementItem[]>(initialEvenements || []);
|
||||||
|
const [menuStructure, setMenuStructure] = useState<any>(initialMenuStructure || null);
|
||||||
const [horairesDataList, setHorairesDataList] = useState<HoraireItem[]>(initialHoraires || []);
|
const [horairesDataList, setHorairesDataList] = useState<HoraireItem[]>(initialHoraires || []);
|
||||||
|
const [garesTypeMap, setGaresTypeMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialInfoTrafic && initialInfoTrafic.length > 0) {
|
if (initialInfoTrafic && initialInfoTrafic.length > 0) {
|
||||||
@@ -568,11 +590,17 @@ export default function RegionContent({
|
|||||||
if (initialActualites && initialActualites.length > 0) {
|
if (initialActualites && initialActualites.length > 0) {
|
||||||
setActusDataList(initialActualites);
|
setActusDataList(initialActualites);
|
||||||
}
|
}
|
||||||
|
if (initialEvenements && initialEvenements.length > 0) {
|
||||||
|
setEvenementsDataList(initialEvenements);
|
||||||
|
}
|
||||||
|
if (initialMenuStructure) {
|
||||||
|
setMenuStructure(initialMenuStructure);
|
||||||
|
}
|
||||||
if (initialHoraires && initialHoraires.length > 0) {
|
if (initialHoraires && initialHoraires.length > 0) {
|
||||||
setHorairesDataList(initialHoraires);
|
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 () => {
|
const fetchLiveSupabase = async () => {
|
||||||
try {
|
try {
|
||||||
// 1. Info trafic
|
// 1. Info trafic
|
||||||
@@ -580,6 +608,7 @@ export default function RegionContent({
|
|||||||
.from('info_trafic')
|
.from('info_trafic')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('actif', true)
|
.eq('actif', true)
|
||||||
|
.in('region_id', regionCandidates)
|
||||||
.is('deleted_at', null)
|
.is('deleted_at', null)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
if (tData) {
|
if (tData) {
|
||||||
@@ -591,6 +620,7 @@ export default function RegionContent({
|
|||||||
.from('actualites')
|
.from('actualites')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('actif', true)
|
.eq('actif', true)
|
||||||
|
.in('region_id', regionCandidates)
|
||||||
.is('deleted_at', null)
|
.is('deleted_at', null)
|
||||||
.order('date_publication', { ascending: false })
|
.order('date_publication', { ascending: false })
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
@@ -598,11 +628,39 @@ export default function RegionContent({
|
|||||||
setActusDataList(aData as ActualiteItem[]);
|
setActusDataList(aData as ActualiteItem[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Gares & Horaires
|
// 3. Evenements
|
||||||
const { data: gData } = await supabase.from('gares').select('id, nom');
|
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<string, string> = {};
|
const gMap: Record<string, string> = {};
|
||||||
|
const gTypeMap: Record<string, string> = {};
|
||||||
if (gData) {
|
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
|
const { data: hData } = await supabase
|
||||||
@@ -624,19 +682,13 @@ export default function RegionContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchLiveSupabase();
|
fetchLiveSupabase();
|
||||||
}, [initialInfoTrafic, initialActualites, initialHoraires, regionSlug]);
|
}, [initialInfoTrafic, initialActualites, initialEvenements, initialMenuStructure, initialHoraires, regionSlug]);
|
||||||
|
|
||||||
// Filter traffic for this region or take all active DB records
|
// Strict regional filtering: NO fallback to other regions
|
||||||
const regionSpecificTraffics = trafficDataList.filter(t =>
|
const trafficItems: InfoTraficItem[] = trafficDataList.filter(t =>
|
||||||
!t.region_id || t.region_id === regionSlug ||
|
!t.region_id || regionCandidates.includes(t.region_id)
|
||||||
(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')
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const trafficItems: InfoTraficItem[] = regionSpecificTraffics.length > 0
|
|
||||||
? regionSpecificTraffics
|
|
||||||
: trafficDataList;
|
|
||||||
|
|
||||||
const currentTrafficIndex = (trafficPage - 1) % (trafficItems.length || 1);
|
const currentTrafficIndex = (trafficPage - 1) % (trafficItems.length || 1);
|
||||||
const currentTraffic: InfoTraficItem | null = trafficItems.length > 0
|
const currentTraffic: InfoTraficItem | null = trafficItems.length > 0
|
||||||
? trafficItems[currentTrafficIndex]
|
? trafficItems[currentTrafficIndex]
|
||||||
@@ -659,18 +711,17 @@ export default function RegionContent({
|
|||||||
return '#e65100';
|
return '#e65100';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter actualités for this region or take all active DB records
|
// Strict regional actualités filter
|
||||||
const regionSpecificActus = actusDataList.filter(a =>
|
const allActusList: ActualiteItem[] = actusDataList.filter(a =>
|
||||||
!a.region_id || a.region_id === regionSlug ||
|
!a.region_id || regionCandidates.includes(a.region_id)
|
||||||
(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')
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const allActusList: ActualiteItem[] = regionSpecificActus.length > 0
|
// Strict regional evenements filter
|
||||||
? regionSpecificActus
|
const evenementsItems: EvenementItem[] = evenementsDataList.filter(e =>
|
||||||
: actusDataList;
|
!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 featuredOnly = allActusList.filter(a => a.mise_en_avant);
|
||||||
const top2Featured: ActualiteItem[] = featuredOnly.length >= 2
|
const top2Featured: ActualiteItem[] = featuredOnly.length >= 2
|
||||||
? featuredOnly.slice(0, 2)
|
? featuredOnly.slice(0, 2)
|
||||||
@@ -693,21 +744,19 @@ export default function RegionContent({
|
|||||||
return `${year}-${month}-${day}`;
|
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()) => {
|
const isTrainCirculating = (h: HoraireItem, targetDate: string = getLocalDateStr()) => {
|
||||||
if (h.actif === false) return false;
|
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) {
|
||||||
if (!Array.isArray(h.jours_personnalises) || h.jours_personnalises.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const customDay = h.jours_personnalises.find(jp => {
|
const customDay = h.jours_personnalises.find(jp => {
|
||||||
const d = jp.date || jp.jour || '';
|
const d = jp.date || jp.jour || '';
|
||||||
return d === targetDate || d.slice(0, 10) === targetDate;
|
return d === targetDate || d.slice(0, 10) === targetDate;
|
||||||
});
|
});
|
||||||
|
if (customDay) return customDay.circule === true;
|
||||||
|
}
|
||||||
|
|
||||||
return customDay?.circule === true;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter and resolve departures for selectedStation taking into account gares_desservies & real quais
|
// 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
|
// 1. Vérification de la circulation du train pour aujourd'hui
|
||||||
if (!isTrainCirculating(h, todayDateStr)) continue;
|
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
|
// 2. Recherche dans la balise / tableau "gares_desservies" du nom de la gare affichée
|
||||||
const stopIndex = gares.findIndex(g => {
|
const stopIndex = gares.findIndex(g => {
|
||||||
@@ -739,8 +797,18 @@ export default function RegionContent({
|
|||||||
const lastStop = gares[gares.length - 1];
|
const lastStop = gares[gares.length - 1];
|
||||||
const destination = h.gare_arrivee_nom || lastStop?.gare_nom || "Terminus";
|
const destination = h.gare_arrivee_nom || lastStop?.gare_nom || "Terminus";
|
||||||
const departureTime = (stop.heure_depart || '').slice(0, 5);
|
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 trainType = h.type_train || 'Train TER';
|
||||||
const trainNum = h.numero_train || '';
|
const trainNum = h.numero_train || '';
|
||||||
|
|
||||||
@@ -783,29 +851,16 @@ export default function RegionContent({
|
|||||||
// Tri chronologique par heure de départ
|
// Tri chronologique par heure de départ
|
||||||
stationDepartures.sort((a, b) => a.scheduledTime.localeCompare(b.scheduledTime));
|
stationDepartures.sort((a, b) => a.scheduledTime.localeCompare(b.scheduledTime));
|
||||||
|
|
||||||
// Swap departure & arrival
|
// Filter out past departures (where scheduledTime < current time)
|
||||||
const handleSwapStations = () => {
|
const nowTimeStr = (() => {
|
||||||
const temp = departureStation;
|
const n = new Date();
|
||||||
setDepartureStation(arrivalStation);
|
return `${String(n.getHours()).padStart(2, '0')}:${String(n.getMinutes()).padStart(2, '0')}`;
|
||||||
setArrivalStation(temp);
|
})();
|
||||||
};
|
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) => {
|
const handleSelectRoute = (from: string, to: string) => {
|
||||||
setDepartureStation(from);
|
router.push(`/${regionSlug}/horaires`);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add ticket to cart
|
// Add ticket to cart
|
||||||
@@ -814,7 +869,7 @@ export default function RegionContent({
|
|||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
title: `${selectedStation} ➔ ${dep.destName} (${dep.scheduledTime})`,
|
title: `${selectedStation} ➔ ${dep.destName} (${dep.scheduledTime})`,
|
||||||
price: dep.price,
|
price: dep.price,
|
||||||
date: outwardDate
|
date: new Date().toLocaleDateString('fr-FR')
|
||||||
};
|
};
|
||||||
setCartItems(prev => [...prev, newItem]);
|
setCartItems(prev => [...prev, newItem]);
|
||||||
setShowCartDrawer(true);
|
setShowCartDrawer(true);
|
||||||
@@ -832,180 +887,50 @@ export default function RegionContent({
|
|||||||
<div className="bg-white min-h-screen text-[#232323] font-sans antialiased text-[13px]">
|
<div className="bg-white min-h-screen text-[#232323] font-sans antialiased text-[13px]">
|
||||||
|
|
||||||
{/* ================================================================== */}
|
{/* ================================================================== */}
|
||||||
{/* 1. HEADER (RESPONSIVE MOBILE / TABLETTE / DESKTOP) */}
|
{/* 1. STICKY UNIFIED SITE HEADER COMPONENT (IMAGE 1 & 2) */}
|
||||||
{/* ================================================================== */}
|
{/* ================================================================== */}
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-30 shadow-xs">
|
<SiteHeader
|
||||||
<div className="max-w-[1340px] mx-auto px-3 sm:px-6 py-2.5 sm:py-3 flex justify-between items-center">
|
regionSlug={regionSlug}
|
||||||
|
logoUrl={regionData?.logo_header_url || config.heroImage}
|
||||||
{/* Left: Burger Menu (Mobile only) + SNCF VOYAGEURS Logo + Regional Brand Logo */}
|
brandPrimary={config.brandPrimary}
|
||||||
<div className="flex items-center gap-2 sm:gap-3.5">
|
brandSecondary={config.brandSecondary}
|
||||||
{/* Burger Menu Button for Mobile & Tablet */}
|
menuStructure={menuStructure || initialMenuStructure}
|
||||||
<button
|
activeTab={activeTab}
|
||||||
type="button"
|
onTabSelect={setActiveTab}
|
||||||
onClick={() => setIsMobileMenuOpen(true)}
|
|
||||||
className="lg:hidden text-[#0066a4] p-1.5 -ml-1 rounded-md hover:bg-gray-100 transition-colors cursor-pointer flex items-center justify-center"
|
|
||||||
aria-label="Ouvrir le menu"
|
|
||||||
>
|
|
||||||
<svg className="w-7 h-7 text-[#0066a4]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* SNCF Voyageurs Logo */}
|
|
||||||
<div className="flex flex-col items-center cursor-pointer" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Regional Brand Logo (Database logo_header_url or Fallback) */}
|
|
||||||
{regionData?.logo_header_url ? (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<img
|
|
||||||
src={regionData.logo_header_url}
|
|
||||||
alt={regionDisplayName}
|
|
||||||
className="h-9 sm:h-12 max-w-[180px] sm:max-w-[240px] object-contain"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : regionSlug === "provence-alpes-cote-azur" || regionSlug === "sud-provence-alpes-cote-d-azur" ? (
|
{/* ================================================================== */}
|
||||||
<div className="flex items-baseline">
|
{/* 2. BANDEAU JAUNE ÉVÉNEMENT (CONFORME MAQUETTE IMAGE 1) */}
|
||||||
<span className="text-[19px] sm:text-[24px] font-light text-[#0066a4] tracking-tight">TER</span>
|
{/* ================================================================== */}
|
||||||
<span className="text-[21px] sm:text-[26px] font-black text-[#0066a4] ml-1 sm:ml-1.5 tracking-tighter">Z</span>
|
{evenementsItems.length > 0 && (
|
||||||
<span className="text-[21px] sm:text-[26px] font-black text-[#f59e0b] tracking-tighter">OU</span>
|
<div className="w-full bg-[#ffd93b] text-[#111111] py-2 px-4 sm:px-6 border-b border-[#f0ca2b] shadow-2xs">
|
||||||
<span className="text-[21px] sm:text-[26px] font-black text-[#0066a4] tracking-tighter">!</span>
|
<div className="max-w-[1340px] mx-auto flex items-center justify-center gap-2 text-[12.5px] sm:text-[13.5px] font-normal text-center flex-wrap">
|
||||||
</div>
|
<span className="material-icons text-[18px] text-[#111111]">campaign</span>
|
||||||
) : regionSlug === "bourgogne-franche-comte" ? (
|
<span>
|
||||||
<div className="flex flex-col">
|
{evenementsItems[0].titre?.toLowerCase().includes('patrimoine')
|
||||||
<div className="flex items-baseline leading-none">
|
? "Journées du patrimoine : allez-y en train, bus et car avec le pass 6€ !"
|
||||||
<span className="text-[20px] sm:text-[26px] font-black tracking-tight text-[#00743f]">
|
: `${evenementsItems[0].titre}${evenementsItems[0].description ? ` : ${evenementsItems[0].description}` : ''}`}
|
||||||
mobi<span className="text-[#a0c800]">go</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="w-2 sm:w-2.5 h-2 sm:h-2.5 rounded-full ml-0.5 sm:ml-1 self-start mt-0.5 bg-[#f39200]"></span>
|
{evenementsItems[0].lien_externe ? (
|
||||||
</div>
|
<a
|
||||||
<span className="text-[6px] sm:text-[7.5px] uppercase font-bold tracking-wider leading-none mt-0.5 text-[#f39200]">
|
href={evenementsItems[0].lien_externe}
|
||||||
{config.tagline}
|
target="_blank"
|
||||||
</span>
|
rel="noopener noreferrer"
|
||||||
</div>
|
className="font-medium underline hover:opacity-80 ml-1 inline-flex items-center gap-0.5"
|
||||||
) : regionSlug === "bretagne" ? (
|
>
|
||||||
<div className="flex items-baseline">
|
Billets en vente ici en nombre limité ›
|
||||||
<span className="text-[19px] sm:text-[25px] font-black tracking-tight text-[#00838f]">Breizh</span>
|
</a>
|
||||||
<span className="text-[19px] sm:text-[25px] font-black tracking-tight text-gray-900 ml-0.5 sm:ml-1">Go</span>
|
|
||||||
</div>
|
|
||||||
) : regionSlug === "centre-val-de-loire" ? (
|
|
||||||
<div className="flex items-baseline">
|
|
||||||
<span className="text-[20px] sm:text-[26px] font-black tracking-tight text-[#e2001a]">Ré</span>
|
|
||||||
<span className="text-[20px] sm:text-[26px] font-light tracking-tight text-[#e2001a]">mi</span>
|
|
||||||
</div>
|
|
||||||
) : regionSlug === "occitanie" ? (
|
|
||||||
<div className="flex items-baseline">
|
|
||||||
<span className="text-[20px] sm:text-[26px] font-black tracking-tight text-[#e30613]">liO</span>
|
|
||||||
<span className="text-[16px] sm:text-[20px] font-normal tracking-tight text-gray-800 ml-1 sm:ml-1.5">Train</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-baseline">
|
<Link
|
||||||
<span className="text-[18px] sm:text-[23px] font-light tracking-tight" style={{ color: 'var(--region-primary, #0066a4)' }}>
|
href={regionSlug ? `/${regionSlug}/offers` : '/offers'}
|
||||||
{config.brandPrimary}
|
className="font-medium underline hover:opacity-80 ml-1 inline-flex items-center gap-0.5"
|
||||||
</span>
|
>
|
||||||
<span className="text-[19px] sm:text-[24px] font-bold ml-1 sm:ml-1.5 tracking-tight" style={{ color: 'var(--region-primary, #0066a4)' }}>
|
Billets en vente ici en nombre limité ›
|
||||||
{config.brandSecondary}
|
</Link>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Right: Actions */}
|
|
||||||
<div className="flex items-center text-[11px] font-medium text-gray-700">
|
|
||||||
{/* Desktop only: Commande */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowOrderModal(true)}
|
|
||||||
className="hidden md:flex flex-col items-center px-4 group cursor-pointer hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="material-icons text-[22px] mb-0.5"
|
|
||||||
style={{ color: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
confirmation_number
|
|
||||||
</span>
|
|
||||||
<span>Commande</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="hidden md:block w-[1px] h-7 bg-gray-200"></div>
|
|
||||||
|
|
||||||
{/* Panier (Mobile & Desktop) */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCartDrawer(true)}
|
|
||||||
className="flex flex-col items-center px-2.5 sm:px-4 group cursor-pointer hover:opacity-80 transition-opacity relative"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="material-icons text-[22px] sm:text-[24px]"
|
|
||||||
style={{ color: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
shopping_basket
|
|
||||||
</span>
|
|
||||||
<span className="hidden md:inline mt-0.5">Panier</span>
|
|
||||||
{cartItems.length > 0 && (
|
|
||||||
<span
|
|
||||||
className="absolute top-0 right-1 sm:right-3 text-white text-[9px] sm:text-[9.5px] font-bold w-4 h-4 rounded-full flex items-center justify-center animate-pulse"
|
|
||||||
style={{ backgroundColor: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
{cartItems.length}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="w-[1px] h-6 sm:h-7 bg-gray-200"></div>
|
|
||||||
|
|
||||||
{/* Compte (Mobile & Desktop) */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAccountModal(true)}
|
|
||||||
className="flex flex-col items-center px-2.5 sm:px-4 group cursor-pointer hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="material-icons text-[22px] sm:text-[24px]"
|
|
||||||
style={{ color: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
person
|
|
||||||
</span>
|
|
||||||
<span className="hidden md:inline mt-0.5">Compte</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ================================================================== */}
|
|
||||||
{/* 2. NAVIGATION BAR (DESKTOP & GRANDES TABLETTES) */}
|
|
||||||
{/* ================================================================== */}
|
|
||||||
<nav className="hidden lg:block border-b border-gray-200 bg-white shadow-xs">
|
|
||||||
<div className="max-w-[1340px] mx-auto px-6 flex gap-7 text-[12.5px] font-medium text-gray-700 overflow-x-auto scrollbar-hide">
|
|
||||||
{[
|
|
||||||
"Se déplacer",
|
|
||||||
"Abonnements",
|
|
||||||
"Tarifs et cartes de réduction",
|
|
||||||
"Services et contacts",
|
|
||||||
"Découvrir la région",
|
|
||||||
...(config.navExtraLinks || ["ZOU ! sur la Côte d'Azur", "ZOU! sur Provence-Alpes", "ZOU! sur Marseille-Nice"])
|
|
||||||
].map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={`py-3 transition-colors cursor-pointer border-b-[2.5px] whitespace-nowrap ${
|
|
||||||
activeTab === tab
|
|
||||||
? "font-semibold"
|
|
||||||
: "border-transparent text-gray-700 hover:text-gray-900"
|
|
||||||
}`}
|
|
||||||
style={activeTab === tab ? {
|
|
||||||
borderColor: 'var(--region-primary, #0066a4)',
|
|
||||||
color: 'var(--region-primary, #0066a4)'
|
|
||||||
} : undefined}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* ================================================================== */}
|
{/* ================================================================== */}
|
||||||
{/* 3. HERO & SEARCH BOX (RESPONSIVE CONFORME IMAGE 1) */}
|
{/* 3. HERO & SEARCH BOX (RESPONSIVE CONFORME IMAGE 1) */}
|
||||||
@@ -1035,165 +960,11 @@ export default function RegionContent({
|
|||||||
<div className="max-w-[1340px] mx-auto px-4 sm:px-6 py-4 md:py-7 relative z-10 -mt-12 sm:-mt-16 md:mt-0">
|
<div className="max-w-[1340px] mx-auto px-4 sm:px-6 py-4 md:py-7 relative z-10 -mt-12 sm:-mt-16 md:mt-0">
|
||||||
<div className="flex flex-col lg:flex-row gap-4 md:gap-5 items-stretch lg:items-start">
|
<div className="flex flex-col lg:flex-row gap-4 md:gap-5 items-stretch lg:items-start">
|
||||||
|
|
||||||
{/* ─── BLOC DE RECHERCHE D'HORAIRES (SCALE EXACTE IMAGE 1) ─── */}
|
{/* ─── REUSABLE TIMETABLE SEARCH FORM MODULE (IMAGE 1) ─── */}
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full lg:w-[410px] p-4 sm:p-5 border border-gray-100/60">
|
<TimetableSearchForm
|
||||||
|
className="w-full lg:w-[410px]"
|
||||||
{/* 1. Gare de départ */}
|
onSearch={(params) => setSearchSuccessMessage(`Recherche d'horaire lancée de "${params.departure || 'Gare'}" à "${params.arrival || 'Gare'}" pour le ${params.outwardDate}`)}
|
||||||
<div className="bg-[#f2f2f3] rounded-lg px-3.5 py-3 mb-2.5 flex items-center justify-between hover:bg-[#eaeaea] transition-colors">
|
|
||||||
<div className="flex items-center gap-3 flex-1">
|
|
||||||
<span
|
|
||||||
className="material-icons text-[21px] flex-shrink-0"
|
|
||||||
style={{ color: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
place
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={departureStation}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSwapStations}
|
|
||||||
className="hover:opacity-80 transition-transform active:rotate-180 duration-200 pl-1.5 cursor-pointer"
|
|
||||||
title="Inverser les gares"
|
|
||||||
style={{ color: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
<span className="material-icons text-[20px]">swap_vert</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 2. Gare d'arrivée */}
|
|
||||||
<div className="bg-[#f2f2f3] rounded-lg px-3.5 py-3 mb-1.5 flex items-center gap-3 hover:bg-[#eaeaea] transition-colors">
|
|
||||||
<span
|
|
||||||
className="material-icons text-[21px] flex-shrink-0"
|
|
||||||
style={{ color: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
place
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={arrivalStation}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* + Via */}
|
|
||||||
<div className="text-right pb-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowVia(!showVia)}
|
|
||||||
className="text-[12px] font-medium cursor-pointer hover:underline"
|
|
||||||
style={{ color: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
{showVia ? "- Supprimer Via" : "+ Via"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showVia && (
|
|
||||||
<div className="bg-[#f2f2f3] rounded-lg px-3.5 py-2 mb-2.5 flex items-center gap-2.5 animate-fadeIn">
|
|
||||||
<span className="material-icons text-[18px] text-gray-500">alt_route</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={viaStation}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 3. Aller */}
|
|
||||||
<div className="bg-[#f2f2f3] rounded-lg px-3.5 py-2 mb-2.5 flex items-center justify-between hover:bg-[#eaeaea] transition-colors">
|
|
||||||
<div className="flex items-center gap-3 flex-1">
|
|
||||||
<span
|
|
||||||
className="material-icons text-[20px] flex-shrink-0"
|
|
||||||
style={{ color: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
calendar_today
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[10px] text-gray-500 font-bold uppercase tracking-wider leading-none">Aller</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={outwardDate}
|
|
||||||
onChange={(e) => setOutwardDate(e.target.value)}
|
|
||||||
className="bg-transparent text-[13.5px] font-semibold text-gray-900 leading-tight outline-none w-28"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-[1px] h-6 bg-gray-300 mx-3"></div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={outwardTime}
|
|
||||||
onChange={(e) => setOutwardTime(e.target.value)}
|
|
||||||
className="w-14 bg-transparent text-left font-normal text-[14px] text-gray-900 outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 4. Retour */}
|
|
||||||
<div
|
|
||||||
onClick={() => 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"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 flex-1">
|
|
||||||
<span
|
|
||||||
className="material-icons text-[20px] flex-shrink-0"
|
|
||||||
style={{ color: hasReturn ? 'var(--region-primary, #0066a4)' : '#888' }}
|
|
||||||
>
|
|
||||||
calendar_today
|
|
||||||
</span>
|
|
||||||
<span className={`text-[13.5px] ${hasReturn ? 'text-gray-900 font-semibold' : 'text-gray-600 font-normal'}`}>
|
|
||||||
{hasReturn ? returnDate : "Retour (optionnel)"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-[1px] h-6 bg-gray-300 mx-3"></div>
|
|
||||||
<div className="w-14 text-left font-normal text-[13.5px] text-gray-400">
|
|
||||||
{hasReturn ? returnTime : "--:--"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 5. Voyageurs */}
|
|
||||||
<div
|
|
||||||
onClick={() => 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"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 flex-1">
|
|
||||||
<span
|
|
||||||
className="material-icons text-[21px] flex-shrink-0"
|
|
||||||
style={{ color: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
account_circle
|
|
||||||
</span>
|
|
||||||
<span className="text-[13.5px] text-gray-800 font-normal">
|
|
||||||
{passengerCount} Voyageur{passengerCount > 1 ? 's' : ''}, {selectedDiscountCard}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className="material-icons text-[20px]"
|
|
||||||
style={{ color: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
add
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 6. Rechercher Button (Full width on mobile, right on desktop) */}
|
|
||||||
<div className="flex justify-end pt-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSearchSubmit}
|
|
||||||
className="w-full sm:w-auto text-white font-semibold px-8 py-3 sm:py-2.5 rounded-lg text-[15px] sm:text-[14px] shadow-sm transition-transform active:scale-98 hover:opacity-90 cursor-pointer text-center"
|
|
||||||
style={{ backgroundColor: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
Rechercher
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── SIDE COLUMN (INFO TRAFIC + S'ABONNER) ─── */}
|
{/* ─── SIDE COLUMN (INFO TRAFIC + S'ABONNER) ─── */}
|
||||||
<div className="w-full lg:w-[280px] flex flex-col gap-4">
|
<div className="w-full lg:w-[280px] flex flex-col gap-4">
|
||||||
@@ -1486,7 +1257,7 @@ export default function RegionContent({
|
|||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
title: `Billet ${selectedStation} → ${dep.destName}`,
|
title: `Billet ${selectedStation} → ${dep.destName}`,
|
||||||
price: 12.00,
|
price: 12.00,
|
||||||
date: outwardDate
|
date: new Date().toLocaleDateString('fr-FR')
|
||||||
}]);
|
}]);
|
||||||
setShowCartDrawer(true);
|
setShowCartDrawer(true);
|
||||||
}}
|
}}
|
||||||
@@ -1690,6 +1461,53 @@ export default function RegionContent({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ================================================================== */}
|
||||||
|
{/* 5.5 ÉVÉNEMENTS RÉGIONAUX (BDD SUPABASE) */}
|
||||||
|
{/* ================================================================== */}
|
||||||
|
{evenementsItems.length > 0 && (
|
||||||
|
<section className="bg-gray-50 py-8 border-t border-gray-100">
|
||||||
|
<div className="max-w-[1340px] mx-auto px-6">
|
||||||
|
<h3 className="text-[20px] font-light text-gray-800 mb-5">
|
||||||
|
Événements en {regionDisplayName}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{evenementsItems.map((ev, idx) => (
|
||||||
|
<div key={ev.id || idx} className="bg-white rounded-xl p-5 border border-gray-200 shadow-xs flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
{ev.image_url && (
|
||||||
|
<div className="w-full h-36 rounded-lg overflow-hidden mb-3">
|
||||||
|
<img src={ev.image_url} alt={ev.titre} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h4 className="text-base font-semibold text-gray-900 mb-1">{ev.titre}</h4>
|
||||||
|
{ev.date_debut && (
|
||||||
|
<p className="text-xs text-[#0066a4] font-medium mb-2">
|
||||||
|
Du {new Date(ev.date_debut).toLocaleDateString('fr-FR')} {ev.date_fin ? `au ${new Date(ev.date_fin).toLocaleDateString('fr-FR')}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{ev.description && (
|
||||||
|
<div className="text-xs text-gray-600 line-clamp-3 mb-3">
|
||||||
|
<RichText content={ev.description} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ev.lien_externe && (
|
||||||
|
<a
|
||||||
|
href={ev.lien_externe}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-block text-center bg-gray-100 text-gray-800 text-xs font-semibold py-2 px-4 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
En savoir plus ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ================================================================== */}
|
{/* ================================================================== */}
|
||||||
{/* 6. DESTINATIONS PLÉBISCITÉES */}
|
{/* 6. DESTINATIONS PLÉBISCITÉES */}
|
||||||
{/* ================================================================== */}
|
{/* ================================================================== */}
|
||||||
@@ -1837,60 +1655,6 @@ export default function RegionContent({
|
|||||||
{/* 8. MODALES & TIROIRS */}
|
{/* 8. MODALES & TIROIRS */}
|
||||||
{/* ================================================================== */}
|
{/* ================================================================== */}
|
||||||
|
|
||||||
{/* MODAL PASSAGERS */}
|
|
||||||
{showPassengerModal && (
|
|
||||||
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xs flex items-center justify-center p-4 animate-fadeIn">
|
|
||||||
<div className="bg-white rounded-xl shadow-2xl max-w-sm w-full p-5 relative">
|
|
||||||
<h3 className="text-[17px] font-bold text-gray-900 mb-3">Voyageurs et Cartes</h3>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-xs font-semibold text-gray-700 mb-1.5">Nombre de voyageurs</label>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setPassengerCount(prev => Math.max(1, prev - 1))}
|
|
||||||
className="w-8 h-8 rounded bg-gray-100 font-bold text-base hover:bg-gray-200 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
<span className="text-base font-bold text-gray-900 w-6 text-center">{passengerCount}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setPassengerCount(prev => Math.min(9, prev + 1))}
|
|
||||||
className="w-8 h-8 rounded bg-gray-100 font-bold text-base hover:bg-gray-200 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-5">
|
|
||||||
<label className="block text-xs font-semibold text-gray-700 mb-1.5">Carte de réduction / Abonnement</label>
|
|
||||||
<select
|
|
||||||
value={selectedDiscountCard}
|
|
||||||
onChange={(e) => setSelectedDiscountCard(e.target.value)}
|
|
||||||
className="w-full bg-[#f4f5f6] border border-gray-200 rounded p-2 text-xs text-gray-800 outline-none"
|
|
||||||
>
|
|
||||||
<option value="Sans carte">Sans carte</option>
|
|
||||||
<option value="Carte Avantage Jeune">Carte Avantage Jeune (-30%)</option>
|
|
||||||
<option value="Carte Avantage Adulte">Carte Avantage Adulte (-30%)</option>
|
|
||||||
<option value="Carte Avantage Senior">Carte Avantage Senior (-30%)</option>
|
|
||||||
<option value="Pass ZOU !">Pass ZOU !</option>
|
|
||||||
<option value="Tarif Solidaire">Tarif Solidaire (-75%)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPassengerModal(false)}
|
|
||||||
className="text-white font-semibold px-5 py-2 rounded-lg text-xs shadow-xs cursor-pointer"
|
|
||||||
style={{ backgroundColor: 'var(--region-primary, #0066a4)' }}
|
|
||||||
>
|
|
||||||
Valider
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DRAWER PANIER */}
|
{/* DRAWER PANIER */}
|
||||||
{showCartDrawer && (
|
{showCartDrawer && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/40 backdrop-blur-xs flex justify-end animate-fadeIn">
|
<div className="fixed inset-0 z-50 bg-black/40 backdrop-blur-xs flex justify-end animate-fadeIn">
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
'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 (
|
||||||
|
<header className="sticky top-0 z-50 bg-white shadow-xs border-b border-gray-100">
|
||||||
|
{/* ─── TOP BRANDING & ACTIONS BAR (CONFORME IMG 2) ─── */}
|
||||||
|
<div className="max-w-[1340px] mx-auto px-3 sm:px-6 py-2 sm:py-2.5 flex items-center justify-between">
|
||||||
|
|
||||||
|
{/* Left: Mobile Hamburger Icon (Img 2) & Logos */}
|
||||||
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
className="lg:hidden p-1 hover:bg-gray-100 rounded-lg cursor-pointer flex items-center justify-center"
|
||||||
|
style={{ color: theme.primary }}
|
||||||
|
aria-label="Menu"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[26px] font-bold">
|
||||||
|
{mobileMenuOpen ? 'close' : 'menu'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={regionSlug ? `/${regionSlug}` : '/'}
|
||||||
|
className="flex items-center gap-3 sm:gap-4 hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
{effectiveLogoUrl ? (
|
||||||
|
<img
|
||||||
|
src={effectiveLogoUrl}
|
||||||
|
alt="Logo TER Région"
|
||||||
|
className="h-8 sm:h-10 md:h-11 object-contain max-w-[200px] sm:max-w-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-[20px] sm:text-[24px] font-black text-[#c00] tracking-tighter">
|
||||||
|
SNCF
|
||||||
|
</span>
|
||||||
|
<span className="text-[14px] sm:text-[16px] font-bold text-gray-800 ml-2">
|
||||||
|
TER
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(brandPrimary || brandSecondary) && (
|
||||||
|
<div className="hidden sm:flex flex-col border-l border-gray-200 pl-3">
|
||||||
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wider leading-none">
|
||||||
|
{brandPrimary || "RÉGION"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] font-bold leading-tight" style={{ color: theme.primary }}>
|
||||||
|
{brandSecondary || "BOURGOGNE-FRANCHE-COMTÉ"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Actions (Commande, Panier, Compte - Img 2) */}
|
||||||
|
<div className="flex items-center text-[11px] font-medium text-gray-700">
|
||||||
|
|
||||||
|
{/* Commande */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOrderModal(true)}
|
||||||
|
className="hidden md:flex flex-col items-center px-4 group cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[20px] mb-0.5" style={{ color: theme.primary }}>
|
||||||
|
edit_note
|
||||||
|
</span>
|
||||||
|
<span>Commande</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="hidden md:block w-[1px] h-7 bg-gray-200"></div>
|
||||||
|
|
||||||
|
{/* Panier */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCartDrawer(true)}
|
||||||
|
className="flex flex-col items-center px-2 sm:px-4 group cursor-pointer hover:opacity-80 transition-opacity relative"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[22px] sm:text-[22px]" style={{ color: theme.primary }}>
|
||||||
|
shopping_bag
|
||||||
|
</span>
|
||||||
|
<span className="hidden md:inline mt-0.5">Panier</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-[1px] h-6 sm:h-7 bg-gray-200"></div>
|
||||||
|
|
||||||
|
{/* Compte */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAccountModal(true)}
|
||||||
|
className="flex flex-col items-center px-2 sm:px-4 group cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[22px] sm:text-[22px]" style={{ color: theme.primary }}>
|
||||||
|
person
|
||||||
|
</span>
|
||||||
|
<span className="hidden md:inline mt-0.5">Compte</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── DYNAMIC HEADER MENU (ATTACHED TO HEADER & STICKY) ─── */}
|
||||||
|
<HeaderMenu
|
||||||
|
menuStructure={menuStructure}
|
||||||
|
regionSlug={regionSlug}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabSelect={onTabSelect}
|
||||||
|
mobileMenuOpen={mobileMenuOpen}
|
||||||
|
onMobileMenuToggle={setMobileMenuOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* MODALE COMMANDE */}
|
||||||
|
{showOrderModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4 animate-fadeIn"
|
||||||
|
onClick={() => setShowOrderModal(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 space-y-4 animate-scaleUp"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center border-b pb-3">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">Suivi & Gestion de Commande</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOrderModal(false)}
|
||||||
|
className="text-gray-400 hover:text-black font-bold text-xl cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Saisissez votre numéro de commande pour consulter vos billets et justificatifs de voyage.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex: CMD-89472"
|
||||||
|
className="w-full border border-gray-300 rounded-lg p-3 text-sm outline-none focus:border-[#006837]"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOrderModal(false)}
|
||||||
|
className="px-4 py-2 text-xs font-semibold text-gray-600 hover:bg-gray-100 rounded-lg cursor-pointer"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
alert("Commande recherchée.");
|
||||||
|
setShowOrderModal(false);
|
||||||
|
}}
|
||||||
|
className="px-5 py-2 text-xs font-semibold text-white bg-[#006837] hover:bg-[#00522b] rounded-lg cursor-pointer shadow-xs"
|
||||||
|
>
|
||||||
|
Rechercher
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DRAWER PANIER */}
|
||||||
|
{showCartDrawer && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/50 flex justify-end animate-fadeIn"
|
||||||
|
onClick={() => setShowCartDrawer(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white w-full max-w-sm h-full shadow-2xl p-6 flex flex-col justify-between animate-slideLeft"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center border-b pb-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="material-icons text-[#006837]">shopping_bag</span>
|
||||||
|
<h3 className="text-base font-bold text-gray-900">Votre Panier</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCartDrawer(false)}
|
||||||
|
className="text-gray-400 hover:text-black font-bold text-xl cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="py-8 text-center text-gray-500 text-xs">
|
||||||
|
<span className="material-icons text-4xl text-gray-300 block mb-2">remove_shopping_cart</span>
|
||||||
|
Votre panier est vide pour le moment.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCartDrawer(false)}
|
||||||
|
className="w-full bg-[#006837] text-white py-3 rounded-lg text-xs font-bold hover:bg-[#00522b] cursor-pointer"
|
||||||
|
>
|
||||||
|
Poursuivre la navigation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MODALE COMPTE */}
|
||||||
|
{showAccountModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4 animate-fadeIn"
|
||||||
|
onClick={() => setShowAccountModal(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl shadow-2xl max-w-sm w-full p-6 space-y-4 animate-scaleUp"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center border-b pb-3">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">Espace Mon Compte</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAccountModal(false)}
|
||||||
|
className="text-gray-400 hover:text-black font-bold text-xl cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Adresse email"
|
||||||
|
className="w-full border border-gray-300 rounded-lg p-2.5 text-xs outline-none focus:border-[#006837]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Mot de passe"
|
||||||
|
className="w-full border border-gray-300 rounded-lg p-2.5 text-xs outline-none focus:border-[#006837]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
alert("Connexion réussie.");
|
||||||
|
setShowAccountModal(false);
|
||||||
|
}}
|
||||||
|
className="w-full bg-[#006837] text-white py-2.5 rounded-lg text-xs font-bold hover:bg-[#00522b] cursor-pointer"
|
||||||
|
>
|
||||||
|
Se connecter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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<ActiveField>(null);
|
||||||
|
|
||||||
|
// Station database items
|
||||||
|
const [allStations, setAllStations] = useState<string[]>(DEFAULT_STATIONS);
|
||||||
|
|
||||||
|
// Calendar state for date picker (default September 20, 2026)
|
||||||
|
const [calendarDate, setCalendarDate] = useState<Date>(new Date(2026, 8, 20)); // Month 8 is Sept
|
||||||
|
const [selectedDayNumber, setSelectedDayNumber] = useState<number>(20);
|
||||||
|
|
||||||
|
// Passenger state matching Img 1
|
||||||
|
const [passengers, setPassengers] = useState<PassengerInfo[]>([
|
||||||
|
{ 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 (
|
||||||
|
<div ref={containerRef} className={`flex flex-col lg:flex-row gap-4 lg:gap-5 items-start ${className}`}>
|
||||||
|
|
||||||
|
{/* ================================================================== */}
|
||||||
|
{/* 1. LEFT SEARCH FORM CARD (CONFORME IMG 1 & 2) */}
|
||||||
|
{/* ================================================================== */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-5 sm:p-6 border border-gray-100/70 w-full lg:w-[410px] flex-shrink-0 relative z-10">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
|
||||||
|
{/* 1. Gare de départ */}
|
||||||
|
<div
|
||||||
|
onClick={() => 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]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<span className="material-icons text-[22px] text-[#006837] flex-shrink-0">
|
||||||
|
place
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={departureStation}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSwapStations}
|
||||||
|
className="hover:opacity-80 transition-transform active:rotate-180 duration-200 pl-2 cursor-pointer text-[#006837]"
|
||||||
|
title="Inverser les gares"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[20px]">swap_vert</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Gare d'arrivée */}
|
||||||
|
<div
|
||||||
|
onClick={() => 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]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[22px] text-[#006837] flex-shrink-0">
|
||||||
|
place
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={arrivalStation}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* + Via link */}
|
||||||
|
<div className="text-right -mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowVia(!showVia);
|
||||||
|
if (!showVia) setActiveField('via');
|
||||||
|
}}
|
||||||
|
className="text-[12px] font-medium text-[#006837] cursor-pointer hover:underline"
|
||||||
|
>
|
||||||
|
{showVia ? "- Supprimer Via" : "+ Via"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showVia && (
|
||||||
|
<div
|
||||||
|
onClick={() => 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]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[18px] text-gray-500">alt_route</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={viaStation}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3. Aller */}
|
||||||
|
<div
|
||||||
|
className={`rounded-xl px-4 py-2.5 flex items-center justify-between transition-all ${
|
||||||
|
activeField === 'outwardDate' || activeField === 'outwardTime'
|
||||||
|
? 'bg-[#f7faef] border-2 border-dashed border-[#006837] shadow-xs'
|
||||||
|
: 'bg-[#f2f3f5] border border-transparent hover:bg-[#eaecef]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => setActiveField('outwardDate')}
|
||||||
|
className="flex items-center gap-3 flex-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[20px] text-[#006837] flex-shrink-0">
|
||||||
|
event
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] text-gray-500 font-bold uppercase tracking-wider leading-none">Aller</span>
|
||||||
|
<span className="text-[13.5px] font-semibold text-gray-900 leading-tight">
|
||||||
|
{outwardDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[1px] h-6 bg-gray-300 mx-3"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => setActiveField('outwardTime')}
|
||||||
|
className="w-14 text-left font-normal text-[14px] text-gray-900 cursor-pointer hover:text-[#006837]"
|
||||||
|
>
|
||||||
|
{outwardTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. Retour */}
|
||||||
|
<div
|
||||||
|
className={`rounded-xl px-4 py-3 flex items-center justify-between transition-all ${
|
||||||
|
activeField === 'returnDate' || activeField === 'returnTime'
|
||||||
|
? 'bg-[#f7faef] border-2 border-dashed border-[#006837] shadow-xs'
|
||||||
|
: 'bg-[#f2f3f5] border border-transparent hover:bg-[#eaecef]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setHasReturn(true);
|
||||||
|
setActiveField('returnDate');
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 flex-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[20px] text-[#006837] flex-shrink-0">
|
||||||
|
event
|
||||||
|
</span>
|
||||||
|
<span className={`text-[13.5px] ${hasReturn ? 'text-gray-900 font-semibold' : 'text-gray-600 font-normal'}`}>
|
||||||
|
{hasReturn ? returnDate : "Retour (optionnel)"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[1px] h-6 bg-gray-300 mx-3"></div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setHasReturn(true);
|
||||||
|
setActiveField('returnTime');
|
||||||
|
}}
|
||||||
|
className="w-14 text-left font-normal text-[13.5px] text-gray-700 cursor-pointer hover:text-[#006837]"
|
||||||
|
>
|
||||||
|
{hasReturn ? returnTime : "--:--"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasReturn && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setHasReturn(false);
|
||||||
|
setReturnDate('Dim. 20 sept.');
|
||||||
|
setReturnTime('--:--');
|
||||||
|
if (activeField === 'returnDate' || activeField === 'returnTime') {
|
||||||
|
setActiveField(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-[#c00] transition-colors p-1 cursor-pointer flex items-center justify-center"
|
||||||
|
title="Supprimer le retour"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[18px]">close</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. Voyageurs (CONFORME IMG 1) */}
|
||||||
|
<div
|
||||||
|
onClick={() => 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]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<span className="material-icons text-[21px] text-[#006837] flex-shrink-0">
|
||||||
|
person
|
||||||
|
</span>
|
||||||
|
<span className="text-[13.5px] text-gray-800 font-normal">
|
||||||
|
{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'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-icons text-[20px] text-[#006837]">
|
||||||
|
add
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. Action Button */}
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full sm:w-auto text-white font-semibold px-8 py-2.5 rounded-lg text-[14px] shadow-sm transition-transform active:scale-98 hover:bg-[#00522b] cursor-pointer text-center bg-[#006837]"
|
||||||
|
>
|
||||||
|
Rechercher
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ================================================================== */}
|
||||||
|
{/* 2. DYNAMIC SIDE PANEL CARD (CONFORME IMG 1, 2, 3, 4, 5) */}
|
||||||
|
{/* ================================================================== */}
|
||||||
|
{activeField && (
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl shadow-xl p-6 border border-gray-100/90 w-full lg:w-[340px] min-h-[400px] flex-shrink-0 relative z-20 animate-fadeIn font-sans"
|
||||||
|
>
|
||||||
|
{/* Left Pointer Arrow (Speech Bubble Triangle) matching Img 1 & 2 */}
|
||||||
|
<div
|
||||||
|
className={`hidden lg:block absolute -left-3 ${getPointerTop()} w-0 h-0 border-t-[10px] border-t-transparent border-b-[10px] border-b-transparent border-r-[12px] border-r-white drop-shadow-xs transition-all duration-200 pointer-events-none`}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* ─── CASE A: STATION SELECTION (GARE DE DÉPART / ARRIVÉE / VIA) (IMG 2 & 3) ─── */}
|
||||||
|
{(activeField === 'departure' || activeField === 'arrival' || activeField === 'via') && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[26px] font-light text-gray-800 tracking-tight whitespace-nowrap mb-1">
|
||||||
|
{activeField === 'departure' ? "Gare de départ" : activeField === 'arrival' ? "Gare d'arrivée" : "Gare de passage"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{currentQuery.length < 2 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-normal text-[#c00] whitespace-nowrap mb-2">
|
||||||
|
Veuillez saisir au moins 2 caractères dans ce champ.
|
||||||
|
</p>
|
||||||
|
<div className="w-full h-[1px] bg-[#c00] mb-4"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-normal text-[#c00] whitespace-nowrap mb-2">
|
||||||
|
Veuillez sélectionner une gare dans cette liste.
|
||||||
|
</p>
|
||||||
|
<div className="w-full h-[1px] bg-[#c00] mb-3"></div>
|
||||||
|
|
||||||
|
<div className="max-h-[300px] overflow-y-auto space-y-2 pr-1 scrollbar-thin">
|
||||||
|
{filteredStations.length > 0 ? (
|
||||||
|
filteredStations.map((st, idx) => {
|
||||||
|
const isFirst = idx === 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={st + idx}
|
||||||
|
onClick={() => 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{st}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-xs text-gray-500">
|
||||||
|
Aucune gare ne correspond à "{currentQuery}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── CASE B: DATE PICKER (ALLER / RETOUR) (IMG 4) ─── */}
|
||||||
|
{(activeField === 'outwardDate' || activeField === 'returnDate') && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[26px] font-light text-gray-800 tracking-tight mb-2">
|
||||||
|
{activeField === 'outwardDate' ? "Date de départ Aller" : "Date de départ Retour"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-[12.5px] text-gray-700 font-normal mb-5 leading-relaxed">
|
||||||
|
Format attendu : "Jour/Mois/Année complète" (exemple: 19/09/2026).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
|
<span className="text-[16px] font-semibold text-gray-800 capitalize">
|
||||||
|
{monthName}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevMonth}
|
||||||
|
type="button"
|
||||||
|
className="p-1 text-gray-400 hover:text-black transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[18px]">chevron_left</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNextMonth}
|
||||||
|
type="button"
|
||||||
|
className="p-1 text-gray-400 hover:text-black transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[18px]">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Grid */}
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center text-[12px] mb-2">
|
||||||
|
{['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => (
|
||||||
|
<span key={i} className="text-gray-500 font-normal py-1">
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center text-[13px]">
|
||||||
|
{/* Blank days for month start alignment */}
|
||||||
|
{Array.from({ length: firstDayOfWeek }).map((_, i) => (
|
||||||
|
<div key={`blank-${i}`} className="py-1.5"></div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<button
|
||||||
|
key={dayNum}
|
||||||
|
type="button"
|
||||||
|
disabled={isPastDate}
|
||||||
|
onClick={() => !isPastDate && handleSelectDay(dayNum)}
|
||||||
|
className={`h-9 w-9 mx-auto rounded-full flex items-center justify-center font-normal transition-all ${
|
||||||
|
isPastDate
|
||||||
|
? 'text-gray-300 opacity-40 cursor-not-allowed bg-gray-50'
|
||||||
|
: isSelected
|
||||||
|
? 'bg-[#c4d600] text-gray-900 font-bold shadow-xs scale-105'
|
||||||
|
: 'hover:bg-gray-100 text-gray-800 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
title={isPastDate ? 'Date passée' : undefined}
|
||||||
|
>
|
||||||
|
{dayNum}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── CASE C: TIME PICKER (ALLER / RETOUR) (IMG 5) ─── */}
|
||||||
|
{(activeField === 'outwardTime' || activeField === 'returnTime') && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[26px] font-light text-gray-800 tracking-tight mb-4">
|
||||||
|
{activeField === 'outwardTime' ? "Heure de départ" : "Heure de retour"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="max-h-[300px] overflow-y-auto scrollbar-thin">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={slot}
|
||||||
|
onClick={() => !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}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── CASE D: VOYAGEUR VOLET (STRICTLY CONFORME IMG 1) ─── */}
|
||||||
|
{activeField === 'passenger' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-[26px] font-light text-gray-800 tracking-tight">
|
||||||
|
Voyageur
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-[340px] overflow-y-auto pr-1">
|
||||||
|
{passengers.map((p, index) => (
|
||||||
|
<div key={p.id} className="space-y-3 relative">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-[#006837] text-white flex items-center justify-center">
|
||||||
|
<span className="material-icons text-[15px]">person</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[14px] font-medium text-gray-900">
|
||||||
|
Voyageur {index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{passengers.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemovePassenger(p.id)}
|
||||||
|
className="text-gray-400 hover:text-[#c00] text-xs font-semibold"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field 1: Âge (optionnel) - Box with dashed green border matching Image 1 */}
|
||||||
|
<div className="border-2 border-dashed border-[#006837] rounded-lg p-2 bg-[#fff] relative">
|
||||||
|
<label className="text-[11px] text-gray-600 font-medium block">
|
||||||
|
Âge (optionnel)
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center justify-between mt-0.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="120"
|
||||||
|
value={p.age}
|
||||||
|
onChange={(e) => handleUpdatePassenger(p.id, 'age', e.target.value)}
|
||||||
|
placeholder="Ex: 30"
|
||||||
|
className="w-full bg-transparent text-[14px] font-semibold text-gray-900 outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col text-gray-400 text-[10px]">
|
||||||
|
<span className="material-icons text-[14px] leading-none">unfold_more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field 2: Carte de réduction (optionnel) - Select matching Image 1 */}
|
||||||
|
<div className="bg-[#f4f5f6] rounded-xl p-3 border border-transparent hover:border-gray-200 transition-colors">
|
||||||
|
<select
|
||||||
|
value={p.discountCard}
|
||||||
|
onChange={(e) => handleUpdatePassenger(p.id, 'discountCard', e.target.value)}
|
||||||
|
className="w-full bg-transparent text-[13px] font-medium text-gray-800 outline-none cursor-pointer appearance-none pr-6"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23006837' viewBox='0 0 24 24'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E")`,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: 'right 0 center',
|
||||||
|
backgroundSize: '20px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DISCOUNT_CARDS.map((card) => (
|
||||||
|
<option key={card} value={card}>{card}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Yellow Horizontal Divider Line (CONFORME IMG 1) */}
|
||||||
|
<div className="w-full border-b border-[#d1d800] my-3"></div>
|
||||||
|
|
||||||
|
{/* + Ajouter un voyageur Green Button (CONFORME IMG 1) */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddPassenger}
|
||||||
|
className="flex items-center gap-1.5 text-[#006837] text-[13.5px] font-semibold cursor-pointer hover:underline"
|
||||||
|
>
|
||||||
|
<span className="text-[18px] font-bold">+</span>
|
||||||
|
<span>Ajouter un voyageur</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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'
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export interface RegionTheme {
|
||||||
|
primary: string;
|
||||||
|
primaryHover: string;
|
||||||
|
primaryLight: string;
|
||||||
|
secondary: string;
|
||||||
|
badgeText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REGION_THEMES: Record<string, RegionTheme> = {
|
||||||
|
'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'];
|
||||||
|
}
|
||||||
Reference in new issue
Block a user