Désactivation des annonces sonores et refonte responsive mobile/tablette des pages de guidage WCS

This commit is contained in:
mathis committed 2026-08-30 22:07:34 +02:00
1 parent 4f3a34b101
commit 983f4f0160
9 files changed
+1315 -842

No files matched your search

+3
View File
@@ -8,6 +8,9 @@
@import "wcs-core/design-tokens/dist/sncf-reseau.css";
/* styles.scss SNCF GROUPE */
@import "wcs-core/design-tokens/dist/sncf-groupe.css";
/* Styles responsive pour les pages de guidage & sélection */
@import "./styles/Launcher.css";
@font-face {
font-family: Avenir;
src: url("/public/fonts/avenir-lighter.woff");
+114 -46
View File
@@ -72,57 +72,125 @@ function App() {
const prefix = source === 'supabase' ? '/ferrovia' : '';
const cleanRoute = (route.replace(/^\/ferrovia/, '') || '/');
// Composant Home explicite (panels cliquables)
// Composant Home moderne & responsive pour Mobile, Tablette et Desktop
function Home({ navigate, prefix, source }) {
const panelStyle = {
width: '42%',
cursor: 'pointer',
padding: 24,
borderRadius: 10,
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
background: '#fff',
textAlign: 'center',
border: '1px solid #e0e8f6',
transition: 'transform 0.15s ease'
};
const POPULAR_HOME_GARES = [
'Paris-Gare-de-Lyon',
'Besançon-Viotte',
'Dijon-Ville',
'Lyon-Part-Dieu',
'Marseille-Saint-Charles',
'Lille-Flandres',
'Strasbourg',
'Bordeaux-Saint-Jean'
];
return (
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '32px 24px', alignItems: 'center', background: '#e9f0ff', borderRadius: '12px' }}>
<div style={{ width: '100%', textAlign: 'center', marginBottom: 20 }}>
<div style={{ display: 'inline-block', padding: '4px 12px', borderRadius: '20px', background: source === 'supabase' ? '#e6f4ea' : '#e8f0fe', color: source === 'supabase' ? '#137333' : '#1a73e8', fontWeight: 600, fontSize: '13px', marginBottom: '8px' }}>
{source === 'supabase' ? 'Mode Base de Données (Ferrovia)' : 'Mode Officiel SNCF (GTFS / Temps Réel)'}
</div>
<h1 style={{ margin: '8px 0', fontSize: 28, color: '#002257' }}>Accueil</h1>
<p style={{ margin: '4px 0 16px', color: '#555' }}>
{source === 'supabase'
? "Consultez les afficheurs basés sur les données de votre BDD Supabase."
: "Consultez les afficheurs connectés aux flux réels GTFS et Open Data SNCF."}
</p>
</div>
<div style={{ display: 'flex', justifyContent: 'space-around', width: '100%', maxWidth: '800px' }}>
<div
role="button"
tabIndex={0}
onClick={() => { try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {} navigate(`${prefix}/departs`); }}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ' ? (function(){ try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {} navigate(`${prefix}/departs`); })() : null)}
style={panelStyle}
aria-label="Accéder aux départs"
>
<h2 style={{ color: '#00439e', marginTop: 0 }}>🚆 Départs</h2>
<p style={{ color: '#666', fontSize: '14px' }}>Consultez les prochains départs d'une gare. Choisissez une gare et le style d'afficheur.</p>
<div className="launcher-container">
{/* Carte de bienvenue */}
<div className="launcher-card">
<div className="launcher-header">
<div>
<h1 className="launcher-title">🚅 Téléaffichage Ferroviaire SNCF</h1>
<p className="launcher-subtitle">
{source === 'supabase'
? "Consultez les afficheurs basés sur les données de votre BDD Supabase."
: "Simulateur d'écrans de gare officiels connectés en direct aux flux GTFS & Open Data SNCF."}
</p>
</div>
<span className={`launcher-badge ${source === 'supabase' ? 'launcher-badge--supabase' : ''}`}>
{source === 'supabase' ? '🗄️ Mode Supabase' : '🌐 Mode SNCF Live'}
</span>
</div>
<div
role="button"
tabIndex={0}
onClick={() => { try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {} navigate(`${prefix}/arrivees`); }}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ' ? (function(){ try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {} navigate(`${prefix}/arrivees`); })() : null)}
style={panelStyle}
aria-label="Accéder aux arrivées"
>
<h2 style={{ color: '#00439e', marginTop: 0 }}>🏁 Arrivées</h2>
<p style={{ color: '#666', fontSize: '14px' }}>Consultez les prochaines arrivées d'une gare en temps réel.</p>
{/* Cartes principales : Départs / Arrivées */}
<div className="launcher-grid launcher-grid--2col">
<div
className="launcher-select-card"
role="button"
tabIndex={0}
onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/departs`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/departs`);
}
}}
>
<div>
<span className="launcher-card-icon">🚆</span>
<span className="launcher-card-tag launcher-card-tag--modern">Départs</span>
<h2 className="launcher-card-title">Prochains Départs</h2>
<p className="launcher-card-desc">
Consultez les trains au départ, leurs voies, retards et dessertes complètes.
</p>
</div>
<div style={{ marginTop: 12 }}>
<span className="launcher-btn launcher-btn--primary">
Consulter les Départs ›
</span>
</div>
</div>
<div
className="launcher-select-card"
role="button"
tabIndex={0}
onClick={() => {
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
navigate(`${prefix}/arrivees`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
navigate(`${prefix}/arrivees`);
}
}}
>
<div>
<span className="launcher-card-icon">🏁</span>
<span className="launcher-card-tag launcher-card-tag--modern">Arrivées</span>
<h2 className="launcher-card-title">Prochaines Arrivées</h2>
<p className="launcher-card-desc">
Suivez en direct les trains attendus en gare, provenances et voies assignées.
</p>
</div>
<div style={{ marginTop: 12 }}>
<span className="launcher-btn launcher-btn--secondary">
Consulter les Arrivées ›
</span>
</div>
</div>
</div>
{/* Raccourcis rapides vers les gares */}
<div className="launcher-chips-container" style={{ marginTop: '8px' }}>
<span className="launcher-chips-label">Accès direct par gare :</span>
<div className="launcher-chips-list">
{POPULAR_HOME_GARES.map((gName) => (
<button
key={gName}
type="button"
className="launcher-chip"
onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/afficheurs/${encodeURIComponent(gName)}?mode=departs`);
}}
>
📍 {gName.replace(/-/g, ' ')}
</button>
))}
</div>
</div>
{/* Présentation des types d'afficheurs */}
<div style={{ borderTop: '1px solid #e2ebf6', paddingTop: '20px', marginTop: '20px' }}>
<div style={{ fontSize: '13px', color: '#5f6368', lineHeight: 1.6 }}>
✨ <strong>Formats disponibles :</strong> Afficheurs Fluides & Lumineux (AFL), Téléaffichage historique (Classiques), Écrans Vidéo Voyageurs (EVA) et Écrans par Quai / Voie.
</div>
</div>
</div>
</div>
+4 -146
View File
@@ -1,148 +1,6 @@
import { useEffect, useRef } from 'react';
import supabase from '../lib/supabase';
// Helper pour normaliser le nom du quai pour comparaison robuste
const normalizeQuai = (val) => {
if (!val) return '';
return String(val)
.toLowerCase()
.replace(/\s+/g, '')
.replace(/voie/g, '')
.replace(/quai/g, '');
};
// Annonces sonores désactivées sur les afficheurs
export default function useAnnonceSonoreRealtime(gareId, affType = null, quaiName = null) {
const audioRef = useRef(null);
const lastAnnonceIdRef = useRef(null);
useEffect(() => {
if (!supabase || !gareId) return;
console.debug(`[useAnnonceSonoreRealtime] Setup for gareId: ${gareId}, type: ${affType}, quai: ${quaiName}`);
const playAnnonce = (newRecord) => {
// 1. Déterminer les afficheurs cibles
const variables = newRecord.annonce?.variables_values || {};
const targetAfficheurs = variables.afficheurs || [];
const trainQuai = variables.quai || '';
// Si targetAfficheurs est renseigné, filtrer
if (affType && targetAfficheurs.length > 0) {
const isTargeted = targetAfficheurs.includes(affType);
if (!isTargeted) {
console.debug(`[useAnnonceSonoreRealtime] Ignoré : cet afficheur (${affType}) n'est pas ciblé par l'annonce.`);
return;
}
// Si l'afficheur est de type "Quais" (se termine par "Quais"), on vérifie la voie/quai
const isQuaiDisplay = affType.toLowerCase().endsWith('quais');
if (isQuaiDisplay && quaiName && trainQuai) {
const normDisplayQuai = normalizeQuai(quaiName);
const normTrainQuai = normalizeQuai(trainQuai);
if (normDisplayQuai !== normTrainQuai) {
console.debug(`[useAnnonceSonoreRealtime] Ignoré : voie non correspondante (Afficheur: ${quaiName}, Train: ${trainQuai})`);
return;
}
}
}
// 1. Couper l'audio en cours de lecture
if (audioRef.current) {
try { audioRef.current.pause(); } catch (e) {}
audioRef.current = null;
}
// 2. Couper le TTS en cours de lecture
if (typeof window !== 'undefined' && window.speechSynthesis) {
try { window.speechSynthesis.cancel(); } catch (e) {}
}
// 3. Diffuser l'annonce
if (newRecord.audio_path) {
const { data } = supabase.storage.from('banque-annonces').getPublicUrl(newRecord.audio_path);
const publicUrl = data?.publicUrl;
if (publicUrl) {
console.debug(`[useAnnonceSonoreRealtime] Lecture audio de : ${publicUrl}`);
const audio = new Audio(publicUrl);
audioRef.current = audio;
audio.play().catch((err) => {
console.error('[useAnnonceSonoreRealtime] Erreur lecture audio:', err);
});
}
} else if (newRecord.tts_texte) {
if (typeof window !== 'undefined' && window.speechSynthesis) {
console.debug(`[useAnnonceSonoreRealtime] Lecture TTS de : "${newRecord.tts_texte}"`);
const utterance = new SpeechSynthesisUtterance(newRecord.tts_texte);
utterance.lang = 'fr-FR';
const voices = window.speechSynthesis.getVoices();
const frenchVoices = voices.filter(v => v.lang && (v.lang.includes('fr-FR') || v.lang.startsWith('fr')));
const claireVoice = frenchVoices.find(v => v.name && v.name.toLowerCase().includes('claire'));
utterance.voice = claireVoice || frenchVoices[0] || null;
window.speechSynthesis.speak(utterance);
}
}
};
// --- FALLBACK POLLING (Seule méthode utilisée) ---
// Puisque le WebSocket vers ton instance auto-hébergée (wss://...) est bloqué par le reverse proxy,
// on désactive l'abonnement Realtime pour éviter les erreurs "CHANNEL_ERROR" dans ta console.
// À la place, on interroge la base de données toutes les 2.5 secondes.
const fetchLatest = async () => {
try {
const { data, error } = await supabase
.from('annonces_diffusions')
.select(`
*,
annonce:annonces_enregistrees(
variables_values
)
`)
.eq('gare_id', gareId)
.order('created_at', { ascending: false })
.limit(1);
if (!error && data && data.length > 0) {
const latest = data[0];
// Si c'est une annonce qu'on n'a pas encore vue
if (latest.id !== lastAnnonceIdRef.current) {
// S'il s'agit du premier chargement, on mémorise juste l'ID sans la jouer
// sauf si on veut vraiment utiliser diffSeconds, mais pour éviter les problèmes d'horloge
// on se fie au fait que lastAnnonceIdRef.current passe de null à l'ID.
if (lastAnnonceIdRef.current === null) {
lastAnnonceIdRef.current = latest.id;
} else {
// C'est une NOUVELLE annonce apparue pendant qu'on polle
console.debug('[useAnnonceSonoreRealtime] Annonce spontanée détectée :', latest);
lastAnnonceIdRef.current = latest.id;
playAnnonce(latest);
}
}
}
} catch (err) {
// Ignorer les erreurs réseau silencieusement
}
};
// On lance le polling toutes les 2.5 secondes pour une réactivité maximale
const pollInterval = setInterval(fetchLatest, 2500);
// On fait aussi un fetch immédiat au démarrage pour initialiser l'ID
fetchLatest();
return () => {
console.debug('[useAnnonceSonoreRealtime] Nettoyage...');
clearInterval(pollInterval);
if (audioRef.current) {
try { audioRef.current.pause(); } catch (e) {}
audioRef.current = null;
}
if (typeof window !== 'undefined' && window.speechSynthesis) {
try { window.speechSynthesis.cancel(); } catch (e) {}
}
};
}, [gareId, affType, quaiName]);
// Désactivé : aucune lecture audio ni TTS n'est déclenchée sur les afficheurs.
return null;
}
+4 -90
View File
@@ -1,92 +1,6 @@
import { useEffect, useRef } from 'react';
// Hook pour générer automatiquement les annonces sur l'afficheur (fallback TTS robuste)
// Hook pour annonces automatiques désactivé sur les afficheurs
export default function useAutoAnnonce(horaires, currentTime, isArrival = false) {
const announcedRefs = useRef(new Set());
const isPlayingRef = useRef(false);
useEffect(() => {
if (!horaires || horaires.length === 0 || !currentTime) return;
// Déterminer le délai d'annonce (2 min avant l'arrivée ou le départ)
const TRIGGER_MINUTES = 2;
const checkAndPlay = () => {
if (isPlayingRef.current) return; // Ne pas interrompre si une annonce joue déjà
for (const train of horaires) {
const numTrain = train.num_train || train.numero_train || train.numero;
if (!numTrain) continue;
const timeStr = isArrival
? (train.heure_arrivee || train.arrival_time || train.time || train.heure)
: (train.heure_depart || train.departure_time || train.time || train.heure);
if (!timeStr) continue;
// Si timeStr est juste "HH:MM", on crée une date du jour avec cette heure
let trainTime;
if (typeof timeStr === 'string' && timeStr.length <= 5 && timeStr.includes(':')) {
const [hh, mm] = timeStr.split(':');
trainTime = new Date(currentTime);
trainTime.setHours(parseInt(hh, 10), parseInt(mm, 10), 0, 0);
} else {
trainTime = new Date(timeStr);
}
if (isNaN(trainTime.getTime())) continue;
const diffMs = trainTime.getTime() - currentTime.getTime();
const diffMins = Math.floor(diffMs / 60000);
// Si on est dans la minute cible et que l'annonce n'a pas encore été jouée
if (diffMins === TRIGGER_MINUTES && !announcedRefs.current.has(numTrain)) {
announcedRefs.current.add(numTrain);
const gareObj = isArrival
? (train.gare_depart_nom || train.gare_depart || train.origine)
: (train.gare_arrivee_nom || train.gare_arrivee || train.destination);
const gareName = typeof gareObj === 'object' && gareObj !== null ? gareObj.nom : gareObj;
let voieText = train.voie ? `Voie ${train.voie}.` : "";
const typeTrain = train.type_train || "train";
const gareText = gareName || "destination inconnue";
const text = isArrival
? `${voieText} Le ${typeTrain} numéro ${numTrain} en provenance de ${gareText}, va entrer en gare. Éloignez-vous de la bordure du quai.`
: `${voieText} Le ${typeTrain} numéro ${numTrain} à destination de ${gareText}, va partir. Prenez garde à la fermeture des portes.`;
// Lancement de l'annonce TTS
isPlayingRef.current = true;
if (typeof window !== 'undefined' && window.speechSynthesis) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'fr-FR';
const voices = window.speechSynthesis.getVoices();
const frenchVoices = voices.filter(v => v.lang && (v.lang.includes('fr-FR') || v.lang.startsWith('fr')));
const claireVoice = frenchVoices.find(v => v.name && v.name.toLowerCase().includes('claire'));
utterance.voice = claireVoice || frenchVoices[0] || null;
utterance.onend = () => {
isPlayingRef.current = false;
};
utterance.onerror = () => {
isPlayingRef.current = false;
};
console.debug(`[useAutoAnnonce] Lecture TTS Automatique: "${text}"`);
window.speechSynthesis.speak(utterance);
} else {
isPlayingRef.current = false;
}
// On s'arrête après avoir lancé une annonce pour éviter de tout superposer
break;
}
}
};
checkAndPlay();
}, [horaires, currentTime, isArrival]);
// Désactivé : aucune lecture TTS automatique
return null;
}
+141 -91
View File
@@ -1,7 +1,21 @@
import React, { useEffect, useState, useMemo } from 'react';
import WcsButton from '../components/WcsButton';
import sessionMemory from '../lib/sessionMemory';
import { getSourceFromPath, searchGares } from '../services/railwayService';
const POPULAR_GARES = [
'Paris-Gare-de-Lyon',
'Besançon-Viotte',
'Dijon-Ville',
'Lyon-Part-Dieu',
'Marseille-Saint-Charles',
'Lille-Flandres',
'Strasbourg',
'Bordeaux-Saint-Jean',
'Nantes',
'Rennes'
];
export default function Arrivees({ onBack, navigate }) {
const source = getSourceFromPath(window.location.pathname);
const prefix = source === 'supabase' ? '/ferrovia' : '';
@@ -10,7 +24,6 @@ export default function Arrivees({ onBack, navigate }) {
const [sncfResults, setSncfResults] = useState([]);
const [loading, setLoading] = useState(source === 'supabase');
const [error, setError] = useState(null);
const [selected, setSelected] = useState('');
const [search, setSearch] = useState('');
// Pour Supabase : charger les gares une seule fois au montage
@@ -51,7 +64,7 @@ export default function Arrivees({ onBack, navigate }) {
} catch (e) {
if (active) setSncfResults([]);
}
}, 200);
}, 180);
return () => {
active = false;
@@ -70,109 +83,146 @@ export default function Arrivees({ onBack, navigate }) {
}, [source, allGares, sncfResults, search]);
const handleSelectGare = (nom) => {
setSelected(nom);
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
if (navigate) {
navigate(`${prefix}/afficheurs/${encodeURIComponent(nom)}?mode=arrivees`);
const cleanNom = String(nom).trim().replace(/\s+/g, '-');
navigate(`${prefix}/afficheurs/${encodeURIComponent(cleanNom)}?mode=arrivees`);
}
};
return (
<div style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h1 style={{ margin: 0 }}>Arrivées</h1>
<span style={{
fontSize: '12px',
fontWeight: '600',
padding: '4px 10px',
borderRadius: '12px',
background: source === 'supabase' ? '#e6f4ea' : '#e8f0fe',
color: source === 'supabase' ? '#137333' : '#1a73e8'
}}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF (GTFS)'}
</span>
<div className="launcher-container">
{/* Stepper */}
<div className="launcher-stepper">
<div className="launcher-step launcher-step--active">
<span className="launcher-step-num">1</span>
<span>Choix de la gare</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step">
<span className="launcher-step-num">2</span>
<span>Type d'afficheur</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step">
<span className="launcher-step-num">3</span>
<span>Options & Affichage</span>
</div>
</div>
{loading && <p>Chargement des gares...</p>}
{error && <p style={{ color: 'red' }}>Erreur : {error}</p>}
{!loading && !error && (
<div>
{/* Barre de recherche */}
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 16 }}>
<input
type="search"
placeholder={source === 'sncf' ? "Rechercher une gare SNCF (ex: Dijon, Paris, Lyon...)" : "Rechercher une gare..."}
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: '60%', padding: '10px 12px', borderRadius: 6, border: '1px solid #ccc' }}
aria-label="Rechercher une gare"
autoFocus
/>
<div className="launcher-card">
{/* Header */}
<div className="launcher-header">
<div>
<h1 className="launcher-title">🏁 Prochaines Arrivées</h1>
<p className="launcher-subtitle">Recherchez une gare pour afficher le tableau des arrivées en temps réel.</p>
</div>
<span className={`launcher-badge ${source === 'supabase' ? 'launcher-badge--supabase' : ''}`}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS Live'}
</span>
</div>
{/* Invitation à la recherche en mode SNCF */}
{source === 'sncf' && search.trim().length < 2 && (
<div style={{ textAlign: 'center', padding: '30px 16px', color: '#666', background: '#fff', borderRadius: '8px', border: '1px dashed #ccc' }}>
<p style={{ margin: 0, fontSize: '15px' }}>
🔍 <strong>Saisissez au moins 2 caractères</strong> pour rechercher parmi les gares du référentiel GTFS SNCF.
</p>
</div>
)}
{/* Aucun résultat */}
{source === 'sncf' && search.trim().length >= 2 && displayedGares.length === 0 && (
<div style={{ textAlign: 'center', padding: '20px', color: '#777' }}>
Aucune gare SNCF trouvée pour "{search}".
</div>
)}
{/* Résultats */}
{displayedGares.length > 0 && (
<div role="list" aria-label="Résultats des gares" style={{ background: '#fff', borderRadius: '8px', padding: '8px 16px', boxShadow: '0 1px 4px rgba(0,0,0,0.06)' }}>
{displayedGares.map((g) => {
const nom = g.nom;
return (
<div key={g.uic || nom} role="listitem">
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 0', cursor: 'pointer' }}
onClick={() => handleSelectGare(nom)}
onKeyDown={(e) => (e.key === 'Enter' ? handleSelectGare(nom) : null)}
tabIndex={0}
>
<div>
<strong style={{ fontSize: '15px' }}>{nom}</strong>
{g.commune && <span style={{ marginLeft: '8px', color: '#666', fontSize: '13px' }}>({g.commune} - {g.departement})</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{g.uic && <span style={{ fontSize: '11px', color: '#888', background: '#f1f3f4', padding: '2px 6px', borderRadius: '4px' }}>UIC {g.uic}</span>}
{selected === nom && <div style={{ color: '#0070f3' }}>Sélectionnée</div>}
</div>
</div>
<div style={{ height: 1, background: '#eef1f6', margin: '2px 0' }} />
</div>
);
})}
</div>
)}
{selected && (
<div style={{ marginTop: 16 }}>
<p>Gare sélectionnée : <strong>{selected}</strong></p>
<button onClick={() => {
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
if (navigate) {
navigate(`${prefix}/afficheurs/${encodeURIComponent(selected)}?mode=arrivees`);
}
}}>Voir les arrivées</button>
</div>
{/* Barre de recherche */}
<div className="launcher-search-box">
<input
type="search"
placeholder="Rechercher une gare (ex: Dijon, Paris, Lyon, Besançon...)"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="launcher-search-input"
aria-label="Rechercher une gare"
autoFocus
/>
{search && (
<button
type="button"
className="launcher-search-clear"
onClick={() => setSearch('')}
aria-label="Effacer la recherche"
>
✕
</button>
)}
</div>
)}
<div style={{ marginTop: 20 }}>
<button onClick={onBack}>Retour</button>
{/* Suggestions rapides / Gares populaires */}
<div className="launcher-chips-container">
<span className="launcher-chips-label">Gares fréquentes :</span>
<div className="launcher-chips-list">
{POPULAR_GARES.map((gName) => (
<button
key={gName}
type="button"
className="launcher-chip"
onClick={() => handleSelectGare(gName)}
>
{gName.replace(/-/g, ' ')}
</button>
))}
</div>
</div>
{loading && <p style={{ color: '#00439e', fontWeight: 600 }}>⏳ Chargement des gares...</p>}
{error && <p style={{ color: '#d70022', fontWeight: 600 }}>⚠️ Erreur : {error}</p>}
{/* Invitation à la recherche en mode SNCF */}
{source === 'sncf' && search.trim().length < 2 && (
<div style={{ textAlign: 'center', padding: '24px 16px', color: '#666', background: '#f8fafc', borderRadius: '8px', border: '1px dashed #d0d7de' }}>
<p style={{ margin: 0, fontSize: '14px' }}>
💡 Saisissez au moins <strong>2 lettres</strong> pour chercher parmi plus de 3 500 gares du réseau français.
</p>
</div>
)}
{/* Aucun résultat */}
{source === 'sncf' && search.trim().length >= 2 && displayedGares.length === 0 && (
<div style={{ textAlign: 'center', padding: '24px', color: '#777', background: '#fff', borderRadius: '8px' }}>
Aucune gare trouvée pour « <strong>{search}</strong> ».
</div>
)}
{/* Liste des résultats */}
{displayedGares.length > 0 && (
<div className="launcher-results-list" role="list">
{displayedGares.map((g) => {
const nom = g.nom;
return (
<div
key={g.uic || nom}
className="launcher-result-item"
onClick={() => handleSelectGare(nom)}
onKeyDown={(e) => (e.key === 'Enter' ? handleSelectGare(nom) : null)}
tabIndex={0}
role="listitem"
>
<div>
<div className="launcher-result-name">{nom}</div>
{g.commune && (
<div className="launcher-result-sub">
{g.commune} {g.departement ? `(${g.departement})` : ''}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{g.uic && <span className="launcher-result-tag">UIC {g.uic}</span>}
<span style={{ color: '#00439e', fontSize: '18px', fontWeight: 'bold' }}>›</span>
</div>
</div>
);
})}
</div>
)}
{/* Navigation retour */}
<div className="launcher-footer-nav">
<WcsButton
mode="plain"
onClick={onBack}
label="← Retour à l'accueil"
/>
</div>
</div>
</div>
);
}
+141 -91
View File
@@ -1,7 +1,21 @@
import React, { useEffect, useState, useMemo } from 'react';
import WcsButton from '../components/WcsButton';
import sessionMemory from '../lib/sessionMemory';
import { getSourceFromPath, searchGares } from '../services/railwayService';
const POPULAR_GARES = [
'Paris-Gare-de-Lyon',
'Besançon-Viotte',
'Dijon-Ville',
'Lyon-Part-Dieu',
'Marseille-Saint-Charles',
'Lille-Flandres',
'Strasbourg',
'Bordeaux-Saint-Jean',
'Nantes',
'Rennes'
];
export default function Departs({ onBack, navigate }) {
const source = getSourceFromPath(window.location.pathname);
const prefix = source === 'supabase' ? '/ferrovia' : '';
@@ -10,7 +24,6 @@ export default function Departs({ onBack, navigate }) {
const [sncfResults, setSncfResults] = useState([]);
const [loading, setLoading] = useState(source === 'supabase');
const [error, setError] = useState(null);
const [selected, setSelected] = useState('');
const [search, setSearch] = useState('');
// Pour Supabase : charger les gares une seule fois au montage
@@ -51,7 +64,7 @@ export default function Departs({ onBack, navigate }) {
} catch (e) {
if (active) setSncfResults([]);
}
}, 200);
}, 180);
return () => {
active = false;
@@ -70,109 +83,146 @@ export default function Departs({ onBack, navigate }) {
}, [source, allGares, sncfResults, search]);
const handleSelectGare = (nom) => {
setSelected(nom);
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
if (navigate) {
navigate(`${prefix}/afficheurs/${encodeURIComponent(nom)}?mode=departs`);
const cleanNom = String(nom).trim().replace(/\s+/g, '-');
navigate(`${prefix}/afficheurs/${encodeURIComponent(cleanNom)}?mode=departs`);
}
};
return (
<div style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h1 style={{ margin: 0 }}>Départs</h1>
<span style={{
fontSize: '12px',
fontWeight: '600',
padding: '4px 10px',
borderRadius: '12px',
background: source === 'supabase' ? '#e6f4ea' : '#e8f0fe',
color: source === 'supabase' ? '#137333' : '#1a73e8'
}}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF (GTFS)'}
</span>
<div className="launcher-container">
{/* Stepper */}
<div className="launcher-stepper">
<div className="launcher-step launcher-step--active">
<span className="launcher-step-num">1</span>
<span>Choix de la gare</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step">
<span className="launcher-step-num">2</span>
<span>Type d'afficheur</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step">
<span className="launcher-step-num">3</span>
<span>Options & Affichage</span>
</div>
</div>
{loading && <p>Chargement des gares...</p>}
{error && <p style={{ color: 'red' }}>Erreur : {error}</p>}
{!loading && !error && (
<div>
{/* Barre de recherche */}
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 16 }}>
<input
type="search"
placeholder={source === 'sncf' ? "Rechercher une gare SNCF (ex: Dijon, Paris, Lyon...)" : "Rechercher une gare..."}
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: '60%', padding: '10px 12px', borderRadius: 6, border: '1px solid #ccc' }}
aria-label="Rechercher une gare"
autoFocus
/>
<div className="launcher-card">
{/* Header */}
<div className="launcher-header">
<div>
<h1 className="launcher-title">🚆 Prochains Départs</h1>
<p className="launcher-subtitle">Recherchez une gare pour afficher le tableau des départs en temps réel.</p>
</div>
<span className={`launcher-badge ${source === 'supabase' ? 'launcher-badge--supabase' : ''}`}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS Live'}
</span>
</div>
{/* Invitation à la recherche en mode SNCF */}
{source === 'sncf' && search.trim().length < 2 && (
<div style={{ textAlign: 'center', padding: '30px 16px', color: '#666', background: '#fff', borderRadius: '8px', border: '1px dashed #ccc' }}>
<p style={{ margin: 0, fontSize: '15px' }}>
🔍 <strong>Saisissez au moins 2 caractères</strong> pour rechercher parmi les gares du référentiel GTFS SNCF.
</p>
</div>
)}
{/* Aucun résultat */}
{source === 'sncf' && search.trim().length >= 2 && displayedGares.length === 0 && (
<div style={{ textAlign: 'center', padding: '20px', color: '#777' }}>
Aucune gare SNCF trouvée pour "{search}".
</div>
)}
{/* Résultats */}
{displayedGares.length > 0 && (
<div role="list" aria-label="Résultats des gares" style={{ background: '#fff', borderRadius: '8px', padding: '8px 16px', boxShadow: '0 1px 4px rgba(0,0,0,0.06)' }}>
{displayedGares.map((g) => {
const nom = g.nom;
return (
<div key={g.uic || nom} role="listitem">
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 0', cursor: 'pointer' }}
onClick={() => handleSelectGare(nom)}
onKeyDown={(e) => (e.key === 'Enter' ? handleSelectGare(nom) : null)}
tabIndex={0}
>
<div>
<strong style={{ fontSize: '15px' }}>{nom}</strong>
{g.commune && <span style={{ marginLeft: '8px', color: '#666', fontSize: '13px' }}>({g.commune} - {g.departement})</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{g.uic && <span style={{ fontSize: '11px', color: '#888', background: '#f1f3f4', padding: '2px 6px', borderRadius: '4px' }}>UIC {g.uic}</span>}
{selected === nom && <div style={{ color: '#0070f3' }}>Sélectionnée</div>}
</div>
</div>
<div style={{ height: 1, background: '#eef1f6', margin: '2px 0' }} />
</div>
);
})}
</div>
)}
{selected && (
<div style={{ marginTop: 16 }}>
<p>Gare sélectionnée : <strong>{selected}</strong></p>
<button onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
if (navigate) {
navigate(`${prefix}/afficheurs/${encodeURIComponent(selected)}?mode=departs`);
}
}}>Voir les départs</button>
</div>
{/* Barre de recherche */}
<div className="launcher-search-box">
<input
type="search"
placeholder="Rechercher une gare (ex: Dijon, Paris, Lyon, Besançon...)"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="launcher-search-input"
aria-label="Rechercher une gare"
autoFocus
/>
{search && (
<button
type="button"
className="launcher-search-clear"
onClick={() => setSearch('')}
aria-label="Effacer la recherche"
>
✕
</button>
)}
</div>
)}
<div style={{ marginTop: 20 }}>
<button onClick={onBack}>Retour</button>
{/* Suggestions rapides / Gares populaires */}
<div className="launcher-chips-container">
<span className="launcher-chips-label">Gares fréquentes :</span>
<div className="launcher-chips-list">
{POPULAR_GARES.map((gName) => (
<button
key={gName}
type="button"
className="launcher-chip"
onClick={() => handleSelectGare(gName)}
>
{gName.replace(/-/g, ' ')}
</button>
))}
</div>
</div>
{loading && <p style={{ color: '#00439e', fontWeight: 600 }}>⏳ Chargement des gares...</p>}
{error && <p style={{ color: '#d70022', fontWeight: 600 }}>⚠️ Erreur : {error}</p>}
{/* Invitation à la recherche en mode SNCF */}
{source === 'sncf' && search.trim().length < 2 && (
<div style={{ textAlign: 'center', padding: '24px 16px', color: '#666', background: '#f8fafc', borderRadius: '8px', border: '1px dashed #d0d7de' }}>
<p style={{ margin: 0, fontSize: '14px' }}>
💡 Saisissez au moins <strong>2 lettres</strong> pour chercher parmi plus de 3 500 gares du réseau français.
</p>
</div>
)}
{/* Aucun résultat */}
{source === 'sncf' && search.trim().length >= 2 && displayedGares.length === 0 && (
<div style={{ textAlign: 'center', padding: '24px', color: '#777', background: '#fff', borderRadius: '8px' }}>
Aucune gare trouvée pour « <strong>{search}</strong> ».
</div>
)}
{/* Liste des résultats */}
{displayedGares.length > 0 && (
<div className="launcher-results-list" role="list">
{displayedGares.map((g) => {
const nom = g.nom;
return (
<div
key={g.uic || nom}
className="launcher-result-item"
onClick={() => handleSelectGare(nom)}
onKeyDown={(e) => (e.key === 'Enter' ? handleSelectGare(nom) : null)}
tabIndex={0}
role="listitem"
>
<div>
<div className="launcher-result-name">{nom}</div>
{g.commune && (
<div className="launcher-result-sub">
{g.commune} {g.departement ? `(${g.departement})` : ''}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{g.uic && <span className="launcher-result-tag">UIC {g.uic}</span>}
<span style={{ color: '#00439e', fontSize: '18px', fontWeight: 'bold' }}>›</span>
</div>
</div>
);
})}
</div>
)}
{/* Navigation retour */}
<div className="launcher-footer-nav">
<WcsButton
mode="plain"
onClick={onBack}
label="← Retour à l'accueil"
/>
</div>
</div>
</div>
);
}
+119 -57
View File
@@ -8,10 +8,42 @@ export default function Displays({ onBack, navigate }) {
const prefix = source === 'supabase' ? '/ferrovia' : '';
const types = [
{ id: 'classiques', label: 'Classiques' },
{ id: 'eva', label: 'EVA' },
{ id: 'afl', label: 'AFL' },
{ id: 'transilien', label: 'TRANSILIEN' }
{
id: 'afl',
label: 'AFL',
sublabel: 'Afficheurs Fluides & Lumineux',
tag: 'Recommandé / Moderne',
tagType: 'modern',
icon: '🖥️',
desc: 'Écrans modernes haute lisibilité avec cycles Départs / Arrivées, Informations trafic, Sécurité et Correspondances.'
},
{
id: 'classiques',
label: 'Classiques',
sublabel: 'Téléaffichage Historique',
tag: 'Rétro / Vintage',
tagType: 'retro',
icon: '📺',
desc: 'Reproduction des moniteurs cathodiques et écrans à palettes historiques des gares SNCF.'
},
{
id: 'eva',
label: 'EVA',
sublabel: 'Écrans Vidéo Voyageurs',
tag: 'Gares Connectées',
tagType: 'modern',
icon: '📱',
desc: 'Écrans d\'information enrichis avec multimédia, infogare et dessertes complètes.'
},
{
id: 'transilien',
label: 'Transilien',
sublabel: 'Réseau Île-de-France',
tag: 'Régional IDF',
tagType: 'retro',
icon: '🚊',
desc: 'Afficheurs dédiés aux lignes RER et Transilien SNCF (lignes C, D, E, H, J, L, N, P, R, U).'
}
];
// Nettoyage du chemin pour extraire la gare même avec /ferrovia
@@ -49,68 +81,98 @@ export default function Displays({ onBack, navigate }) {
}
if (!mode) mode = 'departs';
const formattedGareName = gare ? gare.replace(/-/g, ' ') : 'Non sélectionnée';
return (
<div style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h1 style={{ margin: 0 }}>Choisissez un type d'afficheur</h1>
<span style={{
fontSize: '12px',
fontWeight: '600',
padding: '4px 10px',
borderRadius: '12px',
background: source === 'supabase' ? '#e6f4ea' : '#e8f0fe',
color: source === 'supabase' ? '#137333' : '#1a73e8'
}}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF (GTFS)'}
</span>
<div className="launcher-container">
{/* Stepper */}
<div className="launcher-stepper">
<div className="launcher-step launcher-step--completed">
<span className="launcher-step-num">✓</span>
<span>{formattedGareName}</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step launcher-step--active">
<span className="launcher-step-num">2</span>
<span>Type d'afficheur</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step">
<span className="launcher-step-num">3</span>
<span>Options & Affichage</span>
</div>
</div>
{gare && <p>Gare sélectionnée : <strong>{gare}</strong></p>}
<p>Mode sélectionné : <strong>{mode === 'arrivees' ? 'Arrivées' : 'Départs'}</strong></p>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginTop: 16 }}>
{types.map((t) => (
<div
key={t.id}
role="button"
tabIndex={0}
style={{ width: 220, padding: 16, borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', background: '#fff', cursor: 'pointer' }}
onClick={() => {
if (typeof navigate === 'function') {
const kebab = gare ? String(gare).trim().replace(/\s+/g, '-').replace(/\//g, '/') : '';
navigate(`${prefix}/select-afficheur?gare=${encodeURIComponent(kebab)}&mode=${mode}&type=${t.id}`);
return;
}
alert(`${t.label} pour ${gare || '—'}`);
}}
>
<h3 style={{ margin: '0 0 8px' }}>{t.label}</h3>
<p style={{ margin: 0, color: '#555' }}>Afficher le style {t.label} pour la gare {gare || ''}.</p>
<div className="launcher-card">
{/* Header */}
<div className="launcher-header">
<div>
<h1 className="launcher-title">Sélectionnez le style d'afficheur</h1>
<p className="launcher-subtitle">
Gare : <strong style={{ color: '#002257' }}>{formattedGareName}</strong> • Mode : <strong style={{ color: '#00439e' }}>{mode === 'arrivees' ? '🏁 Arrivées' : '🚆 Départs'}</strong>
</p>
</div>
))}
</div>
<span className={`launcher-badge ${source === 'supabase' ? 'launcher-badge--supabase' : ''}`}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS'}
</span>
</div>
<div style={{ marginTop: 24 }}>
<WcsButton
mode="plain"
className="wcs-primary"
onClick={() => {
try {
{/* Grille de sélection tactile des styles d'afficheurs */}
<div className="launcher-grid launcher-grid--4col">
{types.map((t) => (
<div
key={t.id}
className="launcher-select-card"
role="button"
tabIndex={0}
onClick={() => {
if (typeof navigate === 'function') {
const kebab = gare ? String(gare).trim().replace(/\s+/g, '-').replace(/\//g, '/') : '';
navigate(`${prefix}/select-afficheur?gare=${encodeURIComponent(kebab)}&mode=${mode}&type=${t.id}`);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
const kebab = gare ? String(gare).trim().replace(/\s+/g, '-').replace(/\//g, '/') : '';
navigate(`${prefix}/select-afficheur?gare=${encodeURIComponent(kebab)}&mode=${mode}&type=${t.id}`);
}
}}
>
<div>
<span className="launcher-card-icon">{t.icon}</span>
<span className={`launcher-card-tag ${t.tagType === 'retro' ? 'launcher-card-tag--retro' : 'launcher-card-tag--modern'}`}>
{t.tag}
</span>
<h2 className="launcher-card-title">{t.label}</h2>
<div style={{ fontSize: '13px', fontWeight: 600, color: '#00439e', marginBottom: '8px' }}>{t.sublabel}</div>
<p className="launcher-card-desc">{t.desc}</p>
</div>
<div style={{ marginTop: 12 }}>
<span className="launcher-btn launcher-btn--secondary" style={{ padding: '8px 12px', fontSize: '14px' }}>
Choisir ce style ›
</span>
</div>
</div>
))}
</div>
{/* Navigation retour */}
<div className="launcher-footer-nav">
<WcsButton
mode="plain"
onClick={() => {
if (typeof onBack === 'function') {
onBack();
return;
} else if (navigate) {
navigate(`${prefix}/${mode}`);
}
} catch (e) {}
if (typeof window !== 'undefined' && window.history && window.history.length > 1) {
window.history.back();
} else {
window.location.href = prefix || '/';
}
}}
label="Retour"
/>
}}
label="← Changer de gare"
/>
</div>
</div>
</div>
);
}
+245 -321
View File
@@ -108,344 +108,268 @@ export default function SelectAfficheur({ onBack, navigate }) {
};
return (
<div style={{
maxWidth: '780px',
margin: '40px auto',
padding: '32px',
backgroundColor: '#ffffff',
borderRadius: '12px',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h2 style={{
margin: 0,
color: '#002257',
fontSize: '28px',
fontWeight: 700
}}>
Sélection de l'Afficheur
</h2>
<span style={{
fontSize: '12px',
fontWeight: '600',
padding: '4px 10px',
borderRadius: '12px',
background: source === 'supabase' ? '#e6f4ea' : '#e8f0fe',
color: source === 'supabase' ? '#137333' : '#1a73e8'
}}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF (GTFS)'}
</span>
<div className="launcher-container">
{/* Stepper */}
<div className="launcher-stepper">
<div className="launcher-step launcher-step--completed">
<span className="launcher-step-num">✓</span>
<span>{decodedGare || 'Gare'}</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step launcher-step--completed">
<span className="launcher-step-num">✓</span>
<span>{typeParam.toUpperCase()}</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step launcher-step--active">
<span className="launcher-step-num">3</span>
<span>Options & Affichage</span>
</div>
</div>
<p style={{
margin: '0 0 24px 0',
color: '#555555',
fontSize: '16px',
lineHeight: 1.5
}}>
Pour la gare de <strong>{decodedGare}</strong> en mode <strong>{modeParam === 'arrivees' ? 'Arrivées' : 'Départs'}</strong> (Style : <strong>{typeParam.toUpperCase()}</strong>).
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginBottom: '32px' }}>
{/* General Afficheur Option */}
<div style={{
border: '2px solid #e2ebf6',
borderRadius: '8px',
padding: '24px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
alignItems: 'flex-start',
background: '#fafbfc'
}}>
<div style={{ width: '100%' }}>
<h3 style={{ margin: '0 0 12px 0', color: '#00439e', fontSize: '20px' }}>Afficheur Général</h3>
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px', lineHeight: 1.4 }}>
Affiche la liste des trains programmés avec possibilité d'inclure les pages d'informations et services.
<div className="launcher-card">
{/* Header */}
<div className="launcher-header">
<div>
<h1 className="launcher-title">Configuration de l'afficheur</h1>
<p className="launcher-subtitle">
Gare : <strong style={{ color: '#002257' }}>{decodedGare}</strong> • Mode : <strong style={{ color: '#00439e' }}>{modeParam === 'arrivees' ? '🏁 Arrivées' : '🚆 Départs'}</strong> • Style : <strong style={{ color: '#008b47' }}>{typeParam.toUpperCase()}</strong>
</p>
{typeParam === 'afl' && (
<div style={{
background: '#ffffff',
border: '1px solid #d0e1fd',
borderRadius: '8px',
padding: '14px',
marginBottom: '20px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<span style={{ fontSize: '13px', fontWeight: 700, color: '#002257' }}>
Pages à inclure dans la rotation :
</span>
<div style={{ display: 'flex', gap: '6px' }}>
<button
type="button"
onClick={() => selectAllAflPages(true)}
style={{
background: 'none',
border: 'none',
color: '#00439e',
fontSize: '11px',
fontWeight: 600,
cursor: 'pointer',
textDecoration: 'underline'
}}
>
Tout cocher
</button>
<span style={{ color: '#ccc', fontSize: '11px' }}>|</span>
<button
type="button"
onClick={() => selectAllAflPages(false)}
style={{
background: 'none',
border: 'none',
color: '#666',
fontSize: '11px',
fontWeight: 600,
cursor: 'pointer',
textDecoration: 'underline'
}}
>
Horaires seuls
</button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#222', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aflPages.horaires}
onChange={() => toggleAflPage('horaires')}
style={{ width: '16px', height: '16px', accentColor: '#00439e' }}
/>
<span><strong>Page 1 & 2</strong> (Horaires des trains)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#222', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aflPages.infos}
onChange={() => toggleAflPage('infos')}
style={{ width: '16px', height: '16px', accentColor: '#00439e' }}
/>
<span><strong>Informations</strong> (Trafic & Travaux)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#222', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aflPages.securite1}
onChange={() => toggleAflPage('securite1')}
style={{ width: '16px', height: '16px', accentColor: '#00439e' }}
/>
<span><strong>Sécurité 1</strong> (Bagages / Objets délaissés)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#222', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aflPages.securite2}
onChange={() => toggleAflPage('securite2')}
style={{ width: '16px', height: '16px', accentColor: '#00439e' }}
/>
<span><strong>Sécurité 2</strong> (Vigipirate / Alerte 3117)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#222', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aflPages.corresp}
onChange={() => toggleAflPage('corresp')}
style={{ width: '16px', height: '16px', accentColor: '#00439e' }}
/>
<span><strong>Correspondances</strong> (Transports urbains & bus)</span>
</label>
</div>
</div>
)}
</div>
<button
onClick={handleGeneral}
style={{
backgroundColor: '#00439e',
color: '#ffffff',
border: 'none',
borderRadius: '6px',
padding: '12px 20px',
fontSize: '15px',
fontWeight: '600',
cursor: 'pointer',
width: '100%',
transition: 'background-color 0.2s'
}}
>
Accéder à l'Afficheur
</button>
<span className={`launcher-badge ${source === 'supabase' ? 'launcher-badge--supabase' : ''}`}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS'}
</span>
</div>
{/* Platform-specific Afficheur Option */}
<div style={{
border: '2px solid #e2ebf6',
borderRadius: '8px',
padding: '24px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: '#fafbfc'
}}>
<div>
<h3 style={{ margin: '0 0 12px 0', color: '#00439e', fontSize: '20px' }}>Afficheur par Quai</h3>
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px', lineHeight: 1.4 }}>
Affiche le prochain train et sa desserte spécifiquement pour une voie/quai.
</p>
{/* Grille responsive 2 colonnes (1 col sur mobile) */}
<div className="launcher-grid launcher-grid--2col">
{/* Option 1 : Afficheur Général */}
<div className="launcher-select-card" style={{ cursor: 'default' }}>
<div>
<span className="launcher-card-icon">🖥️</span>
<span className="launcher-card-tag launcher-card-tag--modern">Écran Général</span>
<h2 className="launcher-card-title">Afficheur Général (Hall)</h2>
<p className="launcher-card-desc">
Affiche la liste complète des circulations avec basculement automatique des pages.
</p>
{loading ? (
<div style={{ fontSize: '14px', color: '#888', marginBottom: '16px' }}>Chargement des voies...</div>
) : (
<div style={{ marginBottom: '16px' }}>
{platforms.length > 0 ? (
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: '4px' }}>
Sélectionner une voie existante :
</label>
<select
value={selectedPlatform}
onChange={(e) => {
setSelectedPlatform(e.target.value);
setManualPlatform('');
}}
style={{
width: '100%',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #ccc',
fontSize: '14px'
}}
>
{platforms.map(p => (
<option key={p} value={p}>Voie {p}</option>
))}
</select>
{typeParam === 'afl' && (
<div className="launcher-checklist-box">
<div className="launcher-checklist-header">
<span className="launcher-checklist-title">
Pages incluses dans la boucle :
</span>
<div className="launcher-checklist-actions">
<button
type="button"
onClick={() => selectAllAflPages(true)}
className="launcher-link-btn"
>
Tout cocher
</button>
<span style={{ color: '#ccc' }}>|</span>
<button
type="button"
onClick={() => selectAllAflPages(false)}
className="launcher-link-btn"
style={{ color: '#666' }}
>
Horaires seuls
</button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<label className="launcher-checkbox-label">
<input
type="checkbox"
checked={aflPages.horaires}
onChange={() => toggleAflPage('horaires')}
className="launcher-checkbox"
/>
<span><strong>Page 1 & 2</strong> (Horaires des trains)</span>
</label>
<label className="launcher-checkbox-label">
<input
type="checkbox"
checked={aflPages.infos}
onChange={() => toggleAflPage('infos')}
className="launcher-checkbox"
/>
<span><strong>Informations</strong> (Trafic & Travaux)</span>
</label>
<label className="launcher-checkbox-label">
<input
type="checkbox"
checked={aflPages.securite1}
onChange={() => toggleAflPage('securite1')}
className="launcher-checkbox"
/>
<span><strong>Sécurité 1</strong> (Bagages délaissés)</span>
</label>
<label className="launcher-checkbox-label">
<input
type="checkbox"
checked={aflPages.securite2}
onChange={() => toggleAflPage('securite2')}
className="launcher-checkbox"
/>
<span><strong>Sécurité 2</strong> (Vigipirate / 3117)</span>
</label>
<label className="launcher-checkbox-label">
<input
type="checkbox"
checked={aflPages.corresp}
onChange={() => toggleAflPage('corresp')}
className="launcher-checkbox"
/>
<span><strong>Correspondances</strong> (Bus & Tram)</span>
</label>
</div>
</div>
)}
</div>
<button
type="button"
onClick={handleGeneral}
className="launcher-btn launcher-btn--primary"
>
🖥️ Lancer l'Afficheur Général
</button>
</div>
{/* Option 2 : Afficheur par Quai */}
<div className="launcher-select-card" style={{ cursor: 'default' }}>
<div>
<span className="launcher-card-icon">🚉</span>
<span className="launcher-card-tag launcher-card-tag--retro">Écran de Voie</span>
<h2 className="launcher-card-title">Afficheur par Quai</h2>
<p className="launcher-card-desc">
Affiche le prochain train au départ/arrivée avec la liste déroulante des gares desservies sur la voie.
</p>
<div style={{ background: '#f8fafc', border: '1px solid #e2ebf6', borderRadius: '10px', padding: '16px', marginBottom: '20px' }}>
{loading ? (
<div style={{ fontSize: '14px', color: '#00439e', fontWeight: 600 }}>⏳ Détection des voies réelles...</div>
) : (
<div style={{ fontSize: '13px', color: '#ff6b6b', marginBottom: '12px' }}>
Aucune voie détectée automatiquement pour cette gare.
<div>
{platforms.length > 0 ? (
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 700, color: '#002257', marginBottom: '6px' }}>
Voies actives détectées :
</label>
<select
value={selectedPlatform}
onChange={(e) => {
setSelectedPlatform(e.target.value);
setManualPlatform('');
}}
style={{
width: '100%',
padding: '12px',
borderRadius: '8px',
border: '2px solid #c2dbfe',
fontSize: '15px',
fontWeight: 600,
background: '#ffffff',
color: '#002257'
}}
>
{platforms.map(p => (
<option key={p} value={p}>Voie {p}</option>
))}
</select>
</div>
) : (
<div style={{ fontSize: '13px', color: '#666', marginBottom: '16px', background: '#fff', padding: '8px 12px', borderRadius: '6px' }}>
ℹ️ Saisie manuelle de la voie ci-dessous.
</div>
)}
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 700, color: '#002257', marginBottom: '6px' }}>
Ou saisie libre d'une voie :
</label>
<input
type="text"
placeholder="Ex: A, B, 1, 2 bis..."
value={manualPlatform}
onChange={(e) => setManualPlatform(e.target.value)}
style={{
width: '100%',
padding: '12px',
borderRadius: '8px',
border: '2px solid #e2ebf6',
fontSize: '15px',
boxSizing: 'border-box',
background: '#ffffff'
}}
/>
</div>
</div>
)}
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: '4px' }}>
Saisie libre d'une voie :
</label>
<input
type="text"
placeholder="Ex: A, 2 bis, 4..."
value={manualPlatform}
onChange={(e) => setManualPlatform(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #ccc',
fontSize: '14px',
boxSizing: 'border-box'
}}
/>
</div>
</div>
)}
</div>
<button
type="button"
onClick={handlePlatform}
className="launcher-btn launcher-btn--success"
>
🚉 Lancer l'Afficheur de Quai
</button>
</div>
<button
onClick={handlePlatform}
style={{
backgroundColor: '#008b47',
color: '#ffffff',
border: 'none',
borderRadius: '6px',
padding: '12px 20px',
fontSize: '15px',
fontWeight: '600',
cursor: 'pointer',
width: '100%',
transition: 'background-color 0.2s'
</div>
{/* Accès direct aux pages fixes AFL */}
{typeParam === 'afl' && (
<div style={{ borderTop: '1px solid #e2ebf6', paddingTop: '20px', marginBottom: '20px' }}>
<span className="launcher-chips-label">Accès direct à une page fixe AFL :</span>
<div className="launcher-chips-list">
<button type="button" onClick={() => handleSinglePage('1')} className="launcher-chip">
Page 1 (Horaires)
</button>
<button type="button" onClick={() => handleSinglePage('2')} className="launcher-chip">
Page 2 (Horaires)
</button>
<button type="button" onClick={() => handleSinglePage('infos')} className="launcher-chip">
Informations Trafic
</button>
<button type="button" onClick={() => handleSinglePage('securite1')} className="launcher-chip">
Sécurité (Bagages)
</button>
<button type="button" onClick={() => handleSinglePage('securite2')} className="launcher-chip">
Sécurité (Vigipirate / 3117)
</button>
<button type="button" onClick={() => handleSinglePage('corresp')} className="launcher-chip">
Correspondances
</button>
</div>
</div>
)}
{/* Navigation retour */}
<div className="launcher-footer-nav">
<WcsButton
mode="plain"
onClick={() => {
if (typeof onBack === 'function') {
onBack();
} else if (typeof navigate === 'function') {
navigate(`${prefix}/afficheurs?gare=${encodeURIComponent(gareParam)}&mode=${modeParam}`);
}
}}
>
Accéder au Quai
</button>
label="← Retour au choix du style"
/>
</div>
</div>
{typeParam === 'afl' && (
<div style={{
borderTop: '1px solid #eef2f8',
paddingTop: '20px',
marginBottom: '20px'
}}>
<h4 style={{ margin: '0 0 10px 0', fontSize: '14px', color: '#555', fontWeight: 600 }}>
Accès direct à une page AFL spécifique (fixe) :
</h4>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={() => handleSinglePage('1')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Horaires (Page 1)
</button>
<button
onClick={() => handleSinglePage('2')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Horaires (Page 2)
</button>
<button
onClick={() => handleSinglePage('infos')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Informations
</button>
<button
onClick={() => handleSinglePage('securite1')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Sécurité 1
</button>
<button
onClick={() => handleSinglePage('securite2')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Sécurité 2
</button>
<button
onClick={() => handleSinglePage('corresp')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Correspondances
</button>
</div>
</div>
)}
<div style={{
borderTop: '1px solid #eeeeee',
paddingTop: '20px',
display: 'flex',
justifyContent: 'flex-start'
}}>
<WcsButton
mode="plain"
onClick={() => {
if (typeof onBack === 'function') {
onBack();
} else if (typeof navigate === 'function') {
navigate(`${prefix}/afficheurs?gare=${encodeURIComponent(gareParam)}&mode=${modeParam}`);
}
}}
label="Retour aux types d'afficheurs"
/>
</div>
</div>
);
}
+544
View File
@@ -0,0 +1,544 @@
/* ==============================================================================
Styles Responsive & WCS pour les pages de guidage (Accueil, Recherche, Sélection)
Optimisé pour Mobile, Tablette et Desktop
============================================================================== */
:root {
--sncf-primary: #002257;
--sncf-blue: #00439e;
--sncf-blue-light: #e8f0fe;
--sncf-blue-border: #c2dbfe;
--sncf-accent: #d70022;
--sncf-green: #008b47;
--sncf-green-light: #e6f4ea;
--sncf-bg: #f4f7fb;
--sncf-card-bg: #ffffff;
--sncf-text: #1a1a1a;
--sncf-text-secondary: #5f6368;
--sncf-border: #e2ebf6;
--sncf-radius: 12px;
--sncf-shadow: 0 4px 20px rgba(0, 34, 87, 0.08);
--sncf-shadow-hover: 0 8px 28px rgba(0, 34, 87, 0.14);
}
/* Conteneur principal responsive */
.launcher-container {
width: 100%;
max-width: 980px;
margin: 0 auto;
padding: 16px;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
@media (min-width: 768px) {
.launcher-container {
padding: 24px 32px;
}
}
/* Carte principale */
.launcher-card {
background: var(--sncf-card-bg);
border-radius: var(--sncf-radius);
box-shadow: var(--sncf-shadow);
padding: 20px;
margin-bottom: 24px;
border: 1px solid var(--sncf-border);
}
@media (min-width: 768px) {
.launcher-card {
padding: 32px;
}
}
/* Stepper de navigation (Étapes) */
.launcher-stepper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--sncf-border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.launcher-step {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--sncf-text-secondary);
white-space: nowrap;
flex-shrink: 0;
}
.launcher-step--active {
color: var(--sncf-blue);
}
.launcher-step--completed {
color: var(--sncf-green);
}
.launcher-step-num {
width: 24px;
height: 24px;
border-radius: 50%;
background: #e0e8f6;
color: #555;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.launcher-step--active .launcher-step-num {
background: var(--sncf-blue);
color: #ffffff;
}
.launcher-step--completed .launcher-step-num {
background: var(--sncf-green);
color: #ffffff;
}
.launcher-step-separator {
color: #ccc;
font-size: 14px;
}
/* Header & Titres */
.launcher-header {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
@media (min-width: 600px) {
.launcher-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.launcher-title {
margin: 0;
color: var(--sncf-primary);
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
}
@media (min-width: 768px) {
.launcher-title {
font-size: 28px;
}
}
.launcher-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
padding: 6px 12px;
border-radius: 20px;
background: var(--sncf-blue-light);
color: var(--sncf-blue);
width: fit-content;
}
.launcher-badge--supabase {
background: var(--sncf-green-light);
color: var(--sncf-green);
}
.launcher-subtitle {
margin: 0 0 20px 0;
color: var(--sncf-text-secondary);
font-size: 15px;
line-height: 1.5;
}
/* Grille de sélection tactile */
.launcher-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (min-width: 600px) {
.launcher-grid--2col {
grid-template-columns: 1fr 1fr;
gap: 20px;
}
}
@media (min-width: 768px) {
.launcher-grid--4col {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
}
@media (min-width: 1024px) {
.launcher-grid--4col {
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
}
/* Cartes sélectionnables */
.launcher-select-card {
border: 2px solid var(--sncf-border);
border-radius: var(--sncf-radius);
padding: 20px;
display: flex;
flex-direction: column;
justifyContent: space-between;
background: #ffffff;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
text-decoration: none;
color: inherit;
box-sizing: border-box;
}
.launcher-select-card:hover,
.launcher-select-card:focus-visible {
border-color: var(--sncf-blue);
transform: translateY(-2px);
box-shadow: var(--sncf-shadow-hover);
outline: none;
}
.launcher-select-card--selected {
border-color: var(--sncf-blue);
background: #f8fbff;
box-shadow: 0 0 0 1px var(--sncf-blue);
}
.launcher-card-icon {
font-size: 32px;
margin-bottom: 12px;
display: inline-block;
}
.launcher-card-title {
margin: 0 0 8px 0;
color: var(--sncf-blue);
font-size: 19px;
font-weight: 700;
}
.launcher-card-desc {
margin: 0 0 16px 0;
color: var(--sncf-text-secondary);
font-size: 14px;
line-height: 1.4;
flex-grow: 1;
}
.launcher-card-tag {
display: inline-block;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 3px 8px;
border-radius: 4px;
background: #eef3fb;
color: #00439e;
margin-bottom: 12px;
width: fit-content;
}
.launcher-card-tag--retro {
background: #fff0d4;
color: #a35200;
}
.launcher-card-tag--modern {
background: #e6f4ea;
color: #137333;
}
/* Barre de recherche tactile */
.launcher-search-box {
position: relative;
width: 100%;
margin-bottom: 16px;
}
.launcher-search-input {
width: 100%;
padding: 14px 44px 14px 16px;
font-size: 16px;
border: 2px solid var(--sncf-border);
border-radius: 10px;
background: #ffffff;
color: var(--sncf-text);
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
-webkit-appearance: none;
}
.launcher-search-input:focus {
border-color: var(--sncf-blue);
outline: none;
box-shadow: 0 0 0 3px rgba(0, 67, 158, 0.15);
}
.launcher-search-clear {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: #e0e0e0;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 12px;
font-weight: bold;
color: #666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* Raccourcis / Gares populaires (Chips) */
.launcher-chips-container {
margin-bottom: 20px;
}
.launcher-chips-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--sncf-text-secondary);
margin-bottom: 8px;
display: block;
}
.launcher-chips-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.launcher-chip {
background: #ffffff;
border: 1px solid var(--sncf-border);
border-radius: 20px;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
color: var(--sncf-primary);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
user-select: none;
}
.launcher-chip:hover,
.launcher-chip:focus-visible {
background: var(--sncf-blue-light);
border-color: var(--sncf-blue-border);
color: var(--sncf-blue);
}
.launcher-chip--active {
background: var(--sncf-blue);
border-color: var(--sncf-blue);
color: #ffffff;
}
/* Liste des résultats de recherche */
.launcher-results-list {
background: #ffffff;
border: 1px solid var(--sncf-border);
border-radius: 10px;
overflow: hidden;
max-height: 400px;
overflow-y: auto;
box-shadow: var(--sncf-shadow);
margin-bottom: 20px;
}
.launcher-result-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
cursor: pointer;
border-bottom: 1px solid #f0f3f8;
transition: background-color 0.15s ease;
min-height: 48px;
box-sizing: border-box;
}
.launcher-result-item:last-child {
border-bottom: none;
}
.launcher-result-item:hover,
.launcher-result-item:focus-visible {
background: #f2f7ff;
}
.launcher-result-name {
font-size: 15px;
font-weight: 600;
color: var(--sncf-primary);
}
.launcher-result-sub {
font-size: 13px;
color: var(--sncf-text-secondary);
margin-top: 2px;
}
.launcher-result-tag {
font-size: 11px;
font-weight: 600;
background: #edf2f7;
color: #4a5568;
padding: 3px 8px;
border-radius: 6px;
}
/* Checklist interactive des pages AFL */
.launcher-checklist-box {
background: #ffffff;
border: 1px solid var(--sncf-blue-border);
border-radius: 10px;
padding: 16px;
margin-bottom: 20px;
}
.launcher-checklist-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 8px;
}
.launcher-checklist-title {
font-size: 14px;
font-weight: 700;
color: var(--sncf-primary);
}
.launcher-checklist-actions {
display: flex;
gap: 8px;
}
.launcher-link-btn {
background: none;
border: none;
color: var(--sncf-blue);
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.launcher-checkbox-label {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--sncf-text);
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s ease;
user-select: none;
min-height: 40px;
box-sizing: border-box;
}
.launcher-checkbox-label:hover {
background: #f4f8ff;
}
.launcher-checkbox {
width: 20px;
height: 20px;
accent-color: var(--sncf-blue);
cursor: pointer;
flex-shrink: 0;
}
/* Boutons d'action WCS & Standard */
.launcher-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px 20px;
font-size: 15px;
font-weight: 700;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
box-sizing: border-box;
text-align: center;
text-decoration: none;
min-height: 48px;
}
.launcher-btn--primary {
background: var(--sncf-blue);
color: #ffffff;
}
.launcher-btn--primary:hover {
background: #00337c;
}
.launcher-btn--success {
background: var(--sncf-green);
color: #ffffff;
}
.launcher-btn--success:hover {
background: #006e38;
}
.launcher-btn--secondary {
background: #eef3fb;
color: var(--sncf-blue);
border: 1px solid var(--sncf-blue-border);
}
.launcher-btn--secondary:hover {
background: var(--sncf-blue-light);
}
/* Pied de page de navigation (Retour) */
.launcher-footer-nav {
border-top: 1px solid var(--sncf-border);
padding-top: 20px;
display: flex;
justify-content: flex-start;
}