This commit is contained in:
mathis committed 2026-09-24 16:29:30 +02:00
1 parent bc9c88a548
commit 2cb9ce64cb
32 files changed
+2234 -48

No files matched your search

+3 -2
View File
@@ -1,6 +1,7 @@
# Configuration Supabase (indispensable au moment du build Next.js) # Configuration Supabase (indispensable au moment du build Next.js)
NEXT_PUBLIC_SUPABASE_URL=https://votre-projet.supabase.co NEXT_PUBLIC_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr
NEXT_PUBLIC_SUPABASE_ANON_KEY=votre-cle-anon-supabase NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_n0jG59Ggpz2IYa5PW3LlJl_X_0W68og
# Configuration Serveur # Configuration Serveur
PORT=3000 PORT=3000
+13
View File
@@ -0,0 +1,13 @@
const fs = require('fs');
const env = fs.readFileSync('.env.local', 'utf8').split('\n').reduce((acc, line) => {
const [k, ...v] = line.split('=');
if (k && v) acc[k] = v.join('=');
return acc;
}, {});
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('clients').select('*').limit(1);
console.log("clients:", error ? error.message : "Exists");
}
run();
+15
View File
@@ -0,0 +1,15 @@
const fs = require('fs');
const env = fs.readFileSync('.env.local', 'utf8').split('\n').reduce((acc, line) => {
const [k, ...v] = line.split('=');
if (k && v) acc[k] = v.join('=');
return acc;
}, {});
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('profiles').select('*').limit(1);
console.log("Profiles:", error ? error.message : "Exists");
const { data: d2, error: e2 } = await supabase.from('utilisateurs').select('*').limit(1);
console.log("Utilisateurs:", e2 ? e2.message : "Exists");
}
run();
+17
View File
@@ -0,0 +1,17 @@
const fs = require('fs');
const env = fs.readFileSync('.env.local', 'utf8').split('\n').reduce((acc, line) => {
const [k, ...v] = line.split('=');
if (k && v) acc[k] = v.join('=');
return acc;
}, {});
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('utilisateurs').select('*').limit(1);
if (data && data.length > 0) {
console.log(Object.keys(data[0]));
} else {
console.log("No data", error);
}
}
run();
+77
View File
@@ -0,0 +1,77 @@
const fs = require('fs');
let code = fs.readFileSync('src/components/TrainDetailsClient.tsx', 'utf8');
const newPerturbationLogic = `
// Fetch perturbations
const parts = currentDateDisplay.split('/');
const targetIsoDate = parts.length === 3 ? \`\${parts[2]}-\${parts[1]}-\${parts[0]}\` : new Date().toISOString().split('T')[0];
const { data: pertData } = await supabase.from('perturbations').select('*').eq('actif', true);
let currentDelay = 0;
let pArrets: any[] = [];
if (pertData) {
const matchingPert = pertData.find((p: any) => {
const matchTrain = p.horaire_id === h.id || p.horaire_id === h.numero_train;
if (!matchTrain) return false;
const pStart = (p.date_debut || '').slice(0, 10);
const pEnd = (p.date_fin || '').slice(0, 10);
if (pStart && pEnd && pStart <= targetIsoDate && targetIsoDate <= pEnd) return true;
if (p.jours_perturbation && Array.isArray(p.jours_perturbation)) {
return p.jours_perturbation.some((jp: any) => {
const d = (jp.date || jp.dateDebut || '').slice(0, 10);
return d === targetIsoDate;
});
}
return !pStart && !pEnd && (!p.jours_perturbation || p.jours_perturbation.length === 0);
});
if (matchingPert) {
pArrets = Array.isArray(matchingPert.perturbations_arrets) ? matchingPert.perturbations_arrets : [];
// Find max delay in perturbations_arrets
let maxArretsDelay = 0;
pArrets.forEach(pa => {
if (pa.retard_cumule > maxArretsDelay) maxArretsDelay = pa.retard_cumule;
if (pa.retard_entrant > maxArretsDelay) maxArretsDelay = pa.retard_entrant;
});
currentDelay = maxArretsDelay > 0 ? maxArretsDelay : (matchingPert.duree_retard_minutes || 0);
setPerturbationInfo({
type: matchingPert.type_perturbation || 'Retard',
delayMinutes: currentDelay,
message: matchingPert.cause_details || matchingPert.notes || '',
});
setDelayMinutes(currentDelay);
}
}
`;
const newResolveLogic = `
const resolved: StopItem[] = parsedStops.map((s, idx) => {
const prev = s.heure_depart || s.heure_arrivee || '12:00';
let est = prev;
// Get delay specifically for this stop
const stopPert = pArrets.find(pa => pa.gare_nom === s.gare_nom || pa.gare_id === s.gare_id);
const stopDelay = stopPert && stopPert.retard_cumule !== undefined ? stopPert.retard_cumule : currentDelay;
if (stopDelay > 0) {
const [hh, mm] = prev.split(':').map(Number);
const totalMin = (hh * 60 + mm + stopDelay) % 1440;
const newH = String(Math.floor(totalMin / 60)).padStart(2, '0');
const newM = String(totalMin % 60).padStart(2, '0');
est = \`\${newH}:\${newM}\`;
}
`;
// Replace fetch perturbation logic
code = code.replace(/\/\/ Fetch perturbations[\s\S]*?(?=\/\/ Fetch materiel roulant)/, newPerturbationLogic);
// Replace mapping logic
code = code.replace(/const resolved: StopItem\[\] = parsedStops\.map\(\(s, idx\) => \{[\s\S]*?if \(currentDelay > 0\) \{[\s\S]*?est = `\$\{newH\}:\$\{newM\}`;\s*\}/, newResolveLogic);
fs.writeFileSync('src/components/TrainDetailsClient.tsx', code);
+29
View File
@@ -0,0 +1,29 @@
const fs = require('fs');
const paths = [
'src/app/horaires/train/[trainNumber]/page.tsx',
'src/app/[region]/horaires/train/[trainNumber]/page.tsx',
'src/app/se-deplacer/horaires/train/[trainNumber]/page.tsx',
'src/app/[region]/se-deplacer/horaires/train/[trainNumber]/page.tsx'
];
paths.forEach(p => {
if (fs.existsSync(p)) {
let content = fs.readFileSync(p, 'utf8');
// Check if searchParams is already included
if (!content.includes('searchParams')) {
content = content.replace(/params: Promise<\{([\s\S]*?)\}>;/, 'params: Promise<{$1}>;\n searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;');
content = content.replace(/export default async function (.*)\(\{ params \}: (.*)\) \{/, 'export default async function $1({ params, searchParams }: $2) {');
content = content.replace(/const \{ (.*) \} = await params;/, 'const { $1 } = await params;\n const resolvedSearchParams = searchParams ? await searchParams : {};\n const dateStr = typeof resolvedSearchParams.date === \'string\' ? resolvedSearchParams.date : undefined;');
content = content.replace(/<TrainDetailsClient (.*) \/>/, '<TrainDetailsClient $1 dateStr={dateStr} />');
fs.writeFileSync(p, content);
console.log(`Updated ${p}`);
} else {
console.log(`Already has searchParams ${p}`);
}
} else {
console.log(`Not found: ${p}`);
}
});
+38
View File
@@ -0,0 +1,38 @@
const fs = require('fs');
let code = fs.readFileSync('src/components/SmartSearchBar.tsx', 'utf8');
const regexMatchText = `const numericMatch = cleanQuery.match(/\\d+/);`;
const newMatchLogic = `
const numericMatch = cleanQuery.match(/\\d+/);
// Parse "samedi", "dimanche" etc.
let searchDateStr = '';
const days = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const foundDay = days.find(d => cleanQuery.toLowerCase().includes(d));
if (foundDay) {
const today = new Date();
const currentDay = today.getDay();
const targetDay = days.indexOf(foundDay);
let diff = targetDay - currentDay;
// If the day is in the past (e.g. today is Tuesday, user searches for "samedi"), it might mean last Saturday or next Saturday.
// Let's default to the closest one in the future, or if it's "de samedi" and today is Monday, maybe it means 2 days ago?
// Let's just find the closest date (past or future within 7 days)
if (diff > 3) diff -= 7;
else if (diff < -3) diff += 7;
const targetDate = new Date();
targetDate.setDate(today.getDate() + diff);
const dd = String(targetDate.getDate()).padStart(2, '0');
const mm = String(targetDate.getMonth() + 1).padStart(2, '0');
const yyyy = targetDate.getFullYear();
searchDateStr = \`?date=\${dd}/\${mm}/\${yyyy}\`;
}
`;
code = code.replace(regexMatchText, newMatchLogic);
code = code.replace(/url: getRegionPath\(\`\/horaires\/train\/\$\{t\.numero_train\}\`\)/g, "url: getRegionPath(`/horaires/train/${t.numero_train}`) + searchDateStr");
code = code.replace(/url: getRegionPath\(\`\/horaires\/train\/\$\{numStr\}\`\)/g, "url: getRegionPath(`/horaires/train/${numStr}`) + searchDateStr");
fs.writeFileSync('src/components/SmartSearchBar.tsx', code);
+9 -21
View File
@@ -1,52 +1,43 @@
# ============================================================================== # ==============================================================================
# Configuration Nginx pour ter-ferrovia.mrpatator.fr # Configuration Nginx pour ter-ferrovia.mrpatator.fr
# Emplacement recommandé sur le serveur : /etc/nginx/sites-available/ter-ferrovia.mrpatator.fr # Emplacement sur le serveur : /etc/nginx/sites-available/ter-ferrovia.mrpatator.fr
# Lien symbolique : sudo ln -s /etc/nginx/sites-available/ter-ferrovia.mrpatator.fr /etc/nginx/sites-enabled/
# ============================================================================== # ==============================================================================
# Bloc HTTP : Redirection automatique vers HTTPS (une fois le certificat généré) # Redirection HTTP -> HTTPS
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name ter-ferrovia.mrpatator.fr; server_name ter-ferrovia.mrpatator.fr;
# Emplacement pour le challenge Let's Encrypt (Certbot)
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
root /var/www/certbot; root /var/www/certbot;
} }
# Redirection vers HTTPS
location / { location / {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
} }
# Bloc HTTPS : Reverse Proxy vers l'application Next.js (PM2 port 3000) # Bloc HTTPS
server { server {
listen 443 ssl http2; listen 443 ssl;
listen [::]:443 ssl http2; listen [::]:443 ssl;
http2 on;
server_name ter-ferrovia.mrpatator.fr; server_name ter-ferrovia.mrpatator.fr;
# Certificats SSL (Générés par certbot --nginx -d ter-ferrovia.mrpatator.fr) # Certificats SSL Let's Encrypt
ssl_certificate /etc/letsencrypt/live/ter-ferrovia.mrpatator.fr/fullchain.pem; ssl_certificate /etc/letsencrypt/live/ter-ferrovia.mrpatator.fr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ter-ferrovia.mrpatator.fr/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/ter-ferrovia.mrpatator.fr/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Optimisation SSL
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# En-têtes de sécurité # En-têtes de sécurité
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Compression Gzip pour de meilleures performances # Compression Gzip
gzip on; gzip on;
gzip_proxied any; gzip_proxied any;
gzip_comp_level 6; gzip_comp_level 6;
@@ -61,16 +52,14 @@ server {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
} }
# Reverse Proxy principal vers Next.js # Reverse Proxy vers Next.js (PM2 port 3000)
location / { location / {
proxy_pass http://127.0.0.1:3000; proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
# En-têtes pour WebSockets et Server-Sent Events
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
# En-têtes de transmission du client d'origine
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -78,7 +67,6 @@ server {
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Port $server_port;
# Timeouts de proxy
proxy_connect_timeout 60s; proxy_connect_timeout 60s;
proxy_send_timeout 60s; proxy_send_timeout 60s;
proxy_read_timeout 60s; proxy_read_timeout 60s;
+10
View File
@@ -8,6 +8,7 @@
"name": "ferrovia-regional", "name": "ferrovia-regional",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@google/generative-ai": "^0.24.1",
"@supabase/supabase-js": "^2.112.4", "@supabase/supabase-js": "^2.112.4",
"lucide-react": "^1.34.0", "lucide-react": "^1.34.0",
"next": "16.3.3", "next": "16.3.3",
@@ -457,6 +458,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@google/generative-ai": {
"version": "0.24.1",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+1
View File
@@ -9,6 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@google/generative-ai": "^0.24.1",
"@supabase/supabase-js": "^2.112.4", "@supabase/supabase-js": "^2.112.4",
"lucide-react": "^1.34.0", "lucide-react": "^1.34.0",
"next": "16.3.3", "next": "16.3.3",
+7
View File
@@ -0,0 +1,7 @@
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
async function run() {
const { data: p } = await supabase.from('perturbations').select('id, horaire_id, type_perturbation');
console.log('All perts:', p);
}
run();
+18
View File
@@ -0,0 +1,18 @@
const fs = require('fs');
const env = fs.readFileSync('.env.local', 'utf8').split('\n').reduce((acc, line) => {
const [k, ...v] = line.split('=');
if (k && v) acc[k] = v.join('=');
return acc;
}, {});
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('horaires').select('*').limit(1);
if (data && data.length > 0) {
console.log(Object.keys(data[0]));
console.log("Jours circulation:", data[0].jours_circulation);
} else {
console.log("No data", error);
}
}
run();
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import CompteClient from '@/components/CompteClient';
interface ComptePageProps {
params: Promise<{
region: string;
}>;
}
export const metadata = {
title: 'Mon Compte Client | TER Ferrovia',
description: 'Gagnez du temps avec votre compte client TER',
};
export default async function ComptePage({ params }: ComptePageProps) {
const { region } = await params;
return <CompteClient regionSlug={region} />;
}
@@ -0,0 +1,29 @@
import React, { Suspense } from 'react';
import TrainDetailsClient from '@/components/TrainDetailsClient';
interface RegionTrainPageProps {
params: Promise<{
region: string;
trainNumber: string;
}>;
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function RegionTrainPage({ params, searchParams }: RegionTrainPageProps) {
const { region, trainNumber } = await params;
const resolvedSearchParams = searchParams ? await searchParams : {};
const dateStr = typeof resolvedSearchParams.date === 'string' ? resolvedSearchParams.date : undefined;
return (
<Suspense fallback={
<div className="min-h-screen bg-[#f3f4f6] flex items-center justify-center p-8">
<div className="text-center space-y-3">
<div className="w-10 h-10 border-4 border-[#0f172a] border-t-transparent rounded-full animate-spin mx-auto"></div>
<p className="text-sm font-medium text-gray-700">Chargement des détails du train {trainNumber}...</p>
</div>
</div>
}>
<TrainDetailsClient trainNumber={trainNumber} regionSlug={region} dateStr={dateStr} />
</Suspense>
);
}
@@ -0,0 +1,29 @@
import React, { Suspense } from 'react';
import TrainDetailsClient from '@/components/TrainDetailsClient';
interface RegionTrainPageProps {
params: Promise<{
region: string;
trainNumber: string;
}>;
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function RegionTrainPage({ params, searchParams }: RegionTrainPageProps) {
const { region, trainNumber } = await params;
const resolvedSearchParams = searchParams ? await searchParams : {};
const dateStr = typeof resolvedSearchParams.date === 'string' ? resolvedSearchParams.date : undefined;
return (
<Suspense fallback={
<div className="min-h-screen bg-[#f3f4f6] flex items-center justify-center p-8">
<div className="text-center space-y-3">
<div className="w-10 h-10 border-4 border-[#0f172a] border-t-transparent rounded-full animate-spin mx-auto"></div>
<p className="text-sm font-medium text-gray-700">Chargement des détails du train {trainNumber}...</p>
</div>
</div>
}>
<TrainDetailsClient trainNumber={trainNumber} regionSlug={region} dateStr={dateStr} />
</Suspense>
);
}
+220
View File
@@ -0,0 +1,220 @@
import { NextResponse } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { supabase } from '@/lib/supabase';
import { isTrainActiveOnDate } from '@/lib/circulation';
export async function POST(req: Request) {
try {
const body = await req.json();
const { query, regionSlug, currentDate } = body;
if (!query) {
return NextResponse.json({ error: "No query provided" }, { status: 400 });
}
let parsedIntent: any = { intent: 'unknown' };
const apiKey = process.env.GEMINI_API_KEY;
if (apiKey) {
try {
const genAI = new GoogleGenerativeAI(apiKey);
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
const prompt = `
Tu es un assistant de recherche pour un site régional de trains (TER).
Date actuelle : ${currentDate}.
L'utilisateur recherche : "${query}"
Analyse l'intention de l'utilisateur et retourne UNIQUEMENT un objet JSON avec cette structure :
{
"intent": "train" | "itineraire" | "fiche_horaire" | "gare" | "unknown",
"trainNumber": "numéro à 6 chiffres (ex: 891811) s'il cherche un train précis",
"date": "YYYY-MM-DD s'il précise une date ou un jour (ex: demain, lundi, 24 sept), sinon null",
"departureStation": "nom de la gare de départ s'il cherche un itinéraire",
"arrivalStation": "nom de la gare d'arrivée s'il cherche un itinéraire",
"station": "nom de la gare s'il cherche une gare ou ses fiches horaires",
"line": "nom de la ligne (ex: Dijon Besançon) s'il cherche une fiche horaire de ligne"
}
Règles :
- "train 891811 demain" -> intent: train, trainNumber: "891811", date: [date de demain]
- "horaires dijon besancon" ou "itinéraire de dijon à seurre" -> intent: itineraire, departureStation: "Dijon", arrivalStation: "Besancon" (ou "Seurre")
- "fiches horaires beaune" -> intent: fiche_horaire, station: "Beaune"
- "gare de dijon" -> intent: gare, station: "Dijon"
`;
const result = await model.generateContent(prompt);
const text = result.response.text();
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (jsonMatch) {
parsedIntent = JSON.parse(jsonMatch[0]);
}
} catch (err) {
console.error("AI Error:", err);
}
}
// Fallback if AI fails or no API key
if (parsedIntent.intent === 'unknown') {
const q = query.toLowerCase();
const numMatch = q.match(/(\d{5,6})/);
let fallbackDate = null;
const days = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const foundDay = days.find(d => q.includes(d));
if (q.includes('demain')) {
const d = new Date(currentDate);
d.setDate(d.getDate() + 1);
fallbackDate = d.toISOString().split('T')[0];
} else if (q.includes('aujourd\'hui')) {
fallbackDate = currentDate.split('T')[0];
} else if (foundDay) {
const currentDay = new Date(currentDate).getDay();
const targetDay = days.indexOf(foundDay);
let diff = targetDay - currentDay;
if (diff < 0) diff += 7; // next occurrence
if (diff === 0 && q.includes('prochain')) diff += 7;
const targetDate = new Date(currentDate);
targetDate.setDate(targetDate.getDate() + diff);
fallbackDate = targetDate.toISOString().split('T')[0];
}
if (numMatch) {
parsedIntent = { intent: 'train', trainNumber: numMatch[1], date: fallbackDate };
} else if (q.includes('itineraire') || q.includes('itinéraire') || q.includes('trajet')) {
const parts = q.replace(/(itinéraire|itineraire|trajet)s? (de )?/i, '').split(/ à | vers /);
if (parts.length === 2) {
parsedIntent = { intent: 'itineraire', departureStation: parts[0].trim(), arrivalStation: parts[1].trim() };
}
} else if (q.includes('fiche') || q.includes('horaire')) {
if (q.includes('gare')) {
parsedIntent = { intent: 'fiche_horaire', station: q.replace(/fiches? horaires?( de la)? gare( de)?/i, '').trim() };
} else {
const parts = q.split(/ à | vers /);
if (parts.length === 2) {
parsedIntent = { intent: 'itineraire', departureStation: parts[0].replace(/horaires?( de )?/i, '').trim(), arrivalStation: parts[1].trim() };
}
}
} else if (q.includes('gare')) {
parsedIntent = { intent: 'gare', station: q.replace(/gares?( de)?/i, '').trim() };
} else {
// Ultimate fallback: "Dijon à Seurre"
const parts = q.split(/ à | vers /);
if (parts.length === 2) {
parsedIntent = { intent: 'itineraire', departureStation: parts[0].trim(), arrivalStation: parts[1].trim() };
}
}
}
const todayIso = new Date(currentDate).toISOString().split('T')[0];
const targetIsoDate = parsedIntent.date || todayIso;
const targetDateFR = new Date(targetIsoDate).toLocaleDateString('fr-FR');
let actionResponse: any = { type: 'unknown', message: 'Je n\'ai pas bien compris votre recherche.' };
if (parsedIntent.intent === 'train' && parsedIntent.trainNumber) {
const { data: trains } = await supabase
.from('horaires')
.select('*')
.eq('numero_train', parsedIntent.trainNumber)
.eq('actif', true)
.is('deleted_at', null);
if (!trains || trains.length === 0) {
actionResponse = {
type: 'error',
message: `Désolé, nous n'avons pas trouvé le train n°${parsedIntent.trainNumber} dans notre base de données.`
};
} else {
const isActive = trains.some(train => isTrainActiveOnDate(train, targetIsoDate));
if (!isActive) {
actionResponse = {
type: 'error',
message: `Le train ${parsedIntent.trainNumber} ne circule pas le ${targetDateFR}.`
};
} else {
const params = new URLSearchParams();
if (parsedIntent.date) {
const parts = parsedIntent.date.split('-');
if (parts.length === 3) {
params.set('date', `${parts[2]}/${parts[1]}/${parts[0]}`);
}
}
const queryString = params.toString() ? `?${params.toString()}` : '';
actionResponse = {
type: 'redirect',
url: `/${regionSlug}/horaires/train/${parsedIntent.trainNumber}${queryString}`,
message: `Je vous redirige vers les détails du train ${parsedIntent.trainNumber} pour le ${targetDateFR}.`
};
}
}
} else if (parsedIntent.intent === 'itineraire' && parsedIntent.departureStation && parsedIntent.arrivalStation) {
const params = new URLSearchParams();
params.set('departure', parsedIntent.departureStation);
params.set('arrival', parsedIntent.arrivalStation);
if (parsedIntent.date) {
const parts = parsedIntent.date.split('-');
if (parts.length === 3) {
params.set('outwardDate', `Lun. ${parts[2]}/${parts[1]}`); // basic format
}
}
actionResponse = {
type: 'redirect',
url: `/${regionSlug}/horaires?${params.toString()}`,
message: `Recherche de l'itinéraire de ${parsedIntent.departureStation} à ${parsedIntent.arrivalStation}...`
};
} else if (parsedIntent.intent === 'fiche_horaire') {
const params = new URLSearchParams();
if (parsedIntent.station) params.set('gare', parsedIntent.station);
if (parsedIntent.line) params.set('ligne', parsedIntent.line);
actionResponse = {
type: 'redirect',
url: `/${regionSlug}/se-deplacer/fiches-horaires?${params.toString()}`,
message: `Ouverture des fiches horaires...`
};
} else if (parsedIntent.intent === 'gare' && parsedIntent.station) {
const searchName = parsedIntent.station;
const { data: gares } = await supabase
.from('gares')
.select('nom')
.ilike('nom', `%${searchName}%`)
.is('deleted_at', null)
.order('nom')
.limit(5);
if (!gares || gares.length === 0) {
actionResponse = {
type: 'error',
message: `Désolé, nous n'avons trouvé aucune gare correspondant à "${searchName}".`
};
} else if (gares.length === 1 && gares[0].nom.toLowerCase() === searchName.toLowerCase()) {
actionResponse = {
type: 'redirect',
url: `/${regionSlug}/se-deplacer/gares?gare=${encodeURIComponent(gares[0].nom)}`,
message: `Ouverture de la page de la gare de ${gares[0].nom}...`
};
} else {
// Multiple matches or partial match
actionResponse = {
type: 'options',
message: `Plusieurs gares correspondent à votre recherche. Laquelle cherchez-vous ?`,
options: gares.map(g => ({
title: `Gare de ${g.nom}`,
url: `/${regionSlug}/se-deplacer/gares?gare=${encodeURIComponent(g.nom)}`,
icon: 'place'
}))
};
}
}
return NextResponse.json(actionResponse);
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import CompteClient from '@/components/CompteClient';
export const metadata = {
title: 'Mon Compte Client | TER Ferrovia',
description: 'Gagnez du temps avec votre compte client TER',
};
export default function ComptePage() {
return <CompteClient regionSlug="bourgogne-franche-comte" />;
}
@@ -0,0 +1,33 @@
import React, { Suspense } from 'react';
import TrainDetailsClient from '@/components/TrainDetailsClient';
interface TrainPageProps {
params: Promise<{
trainNumber: string;
}>;
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}
export const metadata = {
title: 'Détails du train | TER Ferrovia',
description: 'Consultez les informations en temps réel, composition et arrêts desservis de votre train',
};
export default async function TrainPage({ params, searchParams }: TrainPageProps) {
const { trainNumber } = await params;
const resolvedSearchParams = searchParams ? await searchParams : {};
const dateStr = typeof resolvedSearchParams.date === 'string' ? resolvedSearchParams.date : undefined;
return (
<Suspense fallback={
<div className="min-h-screen bg-[#f3f4f6] flex items-center justify-center p-8">
<div className="text-center space-y-3">
<div className="w-10 h-10 border-4 border-[#0f172a] border-t-transparent rounded-full animate-spin mx-auto"></div>
<p className="text-sm font-medium text-gray-700">Chargement des détails du train {trainNumber}...</p>
</div>
</div>
}>
<TrainDetailsClient trainNumber={trainNumber} regionSlug="bourgogne-franche-comte" dateStr={dateStr} />
</Suspense>
);
}
+77 -20
View File
@@ -1,24 +1,81 @@
import { redirect } from 'next/navigation'; import React from 'react';
import { supabase } from '@/lib/supabase'; import Link from 'next/link';
export const dynamic = 'force-dynamic'; export const metadata = {
export const revalidate = 0; title: 'Portail des Régions | TER Ferrovia',
description: 'Sélectionnez votre région pour accéder aux horaires et services TER.',
};
export default async function HomePage() { const regions = [
const { data } = await supabase { slug: 'bourgogne-franche-comte', name: 'Bourgogne-Franche-Comté', logo: 'https://www.ter.sncf.com/assets/medias/bfc/headerLogo.svg', color: '#006e39' },
.from('parametres') { slug: 'occitanie', name: 'Occitanie', logo: 'https://www.ter.sncf.com/assets/medias/occitanie/headerLogo.svg', color: '#c0003b' },
.select('valeur') { slug: 'sud-provence-alpes-cote-d-azur', name: 'Sud Provence-Alpes-Côte d\'Azur', logo: 'https://www.ter.sncf.com/assets/medias/paca/headerLogo.svg', color: '#0062a0' },
.eq('cle', 'general') { slug: 'bretagne', name: 'Bretagne', logo: 'https://www.ter.sncf.com/assets/medias/bretagne/headerLogo.svg', color: '#00a3a6' },
.single(); { slug: 'grand-est', name: 'Grand Est', logo: 'https://www.ter.sncf.com/assets/medias/ge/headerLogo.svg', color: '#0055a4' },
{ slug: 'normandie', name: 'Normandie', logo: 'https://www.ter.sncf.com/assets/medias/normandie/headerLogo.svg', color: '#e50046' },
{ slug: 'hauts-de-france', name: 'Hauts-de-France', logo: 'https://www.ter.sncf.com/assets/medias/hdf/headerLogo.svg', color: '#008b8b' },
{ slug: 'pays-de-la-loire', name: 'Pays de la Loire', logo: 'https://www.ter.sncf.com/assets/medias/aleop/headerLogo.svg', color: '#e2001a' },
{ slug: 'nouvelle-aquitaine', name: 'Nouvelle-Aquitaine', logo: 'https://www.ter.sncf.com/assets/medias/na/headerLogo.svg', color: '#da291c' },
{ slug: 'centre-val-de-loire', name: 'Centre-Val de Loire', logo: 'https://www.ter.sncf.com/assets/medias/cen/headerLogo.svg', color: '#0064a8' },
{ slug: 'auvergne-rhone-alpes', name: 'Auvergne-Rhône-Alpes', logo: 'https://www.ter.sncf.com/assets/medias/aura/headerLogo.svg', color: '#004f9f' },
];
let targetRegion = 'bourgogne-franche-comte'; export default function PortalPage() {
if (data && data.valeur) { return (
const val = data.valeur as any; <div className="min-h-screen bg-[#f5f6f8] flex flex-col items-center justify-center font-sans">
if (val.region) { <div className="w-full bg-[#111] py-8 text-center border-b-[4px] border-[#961533]">
targetRegion = val.region; <h1 className="text-[32px] sm:text-[40px] font-bold text-white mb-2">
SNCF <span className="font-light">Voyageurs</span>
</h1>
<p className="text-gray-300 text-[16px] sm:text-[18px]">
Portail d'accès aux sites régionaux TER
</p>
</div>
<div className="max-w-[1200px] w-full px-6 py-12 flex-1">
<div className="text-center mb-10">
<h2 className="text-[24px] font-bold text-gray-800">Choisissez votre région</h2>
<p className="text-gray-500 mt-2 text-[15px]">Cliquez sur une région pour accéder à ses horaires, itinéraires et actualités.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{regions.map((region) => (
<Link
key={region.slug}
href={`/${region.slug}`}
className="bg-white rounded-xl shadow-sm hover:shadow-md border border-gray-100 p-6 flex flex-col items-center justify-center transition-all hover:-translate-y-1 group"
>
<div className="h-16 flex items-center justify-center mb-4 w-full px-4">
<img
src={region.logo}
alt={`Logo ${region.name}`}
className="max-h-full max-w-full object-contain filter drop-shadow-xs"
/>
</div>
<h3 className="font-semibold text-gray-800 text-[15px] text-center w-full" style={{ color: region.color }}>
{region.name}
</h3>
<div
className="w-8 h-[3px] mt-4 rounded-full transition-all group-hover:w-16"
style={{ backgroundColor: region.color }}
></div>
</Link>
))}
</div>
<div className="mt-16 text-center">
<div className="inline-block bg-white px-6 py-4 rounded-lg shadow-sm border border-gray-200">
<p className="text-[13px] text-gray-600 flex items-center gap-2">
<span className="material-icons text-blue-600">info</span>
Application conçue pour les déploiements dynamiques Vercel via les slugs <code>/[region]</code>
</p>
</div>
</div>
</div>
<footer className="w-full bg-white border-t border-gray-200 py-6 text-center">
<p className="text-[12px] text-gray-500">© 2026 - SNCF Voyageurs / TER Ferrovia - Prototype Vercel</p>
</footer>
</div>
);
} }
}
redirect(`/${targetRegion}`);
}
@@ -0,0 +1,33 @@
import React, { Suspense } from 'react';
import TrainDetailsClient from '@/components/TrainDetailsClient';
interface TrainPageProps {
params: Promise<{
trainNumber: string;
}>;
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}
export const metadata = {
title: 'Détails du train | TER Ferrovia',
description: 'Consultez les informations en temps réel, composition et arrêts desservis de votre train',
};
export default async function TrainPage({ params, searchParams }: TrainPageProps) {
const { trainNumber } = await params;
const resolvedSearchParams = searchParams ? await searchParams : {};
const dateStr = typeof resolvedSearchParams.date === 'string' ? resolvedSearchParams.date : undefined;
return (
<Suspense fallback={
<div className="min-h-screen bg-[#f3f4f6] flex items-center justify-center p-8">
<div className="text-center space-y-3">
<div className="w-10 h-10 border-4 border-[#0f172a] border-t-transparent rounded-full animate-spin mx-auto"></div>
<p className="text-sm font-medium text-gray-700">Chargement des détails du train {trainNumber}...</p>
</div>
</div>
}>
<TrainDetailsClient trainNumber={trainNumber} regionSlug="bourgogne-franche-comte" dateStr={dateStr} />
</Suspense>
);
}
+544
View File
@@ -0,0 +1,544 @@
"use client";
import React, { useState, useEffect } from 'react';
import { getRegionTheme } from '@/lib/regionTheme';
import SiteHeader from '@/components/SiteHeader';
import SiteFooter from '@/components/SiteFooter';
import { supabase } from '@/lib/supabase';
export default function CompteClient({ regionSlug }: { regionSlug: string }) {
const theme = getRegionTheme(regionSlug);
// 0: Landing, 1: Login SNCF, 2: Dashboard
const [step, setStep] = useState<number>(0);
// Form mode: login or signup
const [isSignUp, setIsSignUp] = useState(false);
// Form data
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [nom, setNom] = useState('');
const [prenom, setPrenom] = useState('');
// Status
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
const [successMsg, setSuccessMsg] = useState('');
// User state
const [user, setUser] = useState<any>(null);
useEffect(() => {
// Check active session
supabase.auth.getSession().then(({ data: { session } }) => {
if (session?.user) {
setUser(session.user);
setStep(2); // directly to dashboard
}
});
const { data: authListener } = supabase.auth.onAuthStateChange((event, session) => {
if (session?.user) {
setUser(session.user);
setStep(2);
} else {
setUser(null);
setStep(0);
}
});
return () => {
authListener.subscription.unsubscribe();
};
}, []);
const handleLoginOrSignup = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setErrorMsg('');
setSuccessMsg('');
if (isSignUp) {
if (!nom || !prenom) {
setErrorMsg('Veuillez remplir votre nom et prénom.');
setLoading(false);
return;
}
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { nom, prenom, date_naissance: '01/01/2000' } // default data
}
});
if (error) setErrorMsg(error.message);
else {
setSuccessMsg('Compte créé avec succès ! Vous pouvez vous connecter.');
setIsSignUp(false);
}
} else {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
if (error) setErrorMsg('Email ou mot de passe incorrect.');
// if success, the onAuthStateChange will trigger and set step=2
}
setLoading(false);
};
const handleLogout = async () => {
setLoading(true);
await supabase.auth.signOut();
setStep(0);
setLoading(false);
};
const handlePasskeyRegister = async () => {
// Fake passkey registration for demo purposes (real WebAuthn requires backend)
if (!user) return;
try {
setLoading(true);
// Simulate WebAuthn delay
await new Promise(res => setTimeout(res, 1000));
localStorage.setItem(`passkey_${user.email}`, 'registered');
setSuccessMsg("Clé d'accès (Passkey) créée avec succès sur cet appareil !");
} catch (err) {
setErrorMsg("Erreur lors de la création de la clé d'accès.");
} finally {
setLoading(false);
}
};
const handlePasskeyLogin = async () => {
setErrorMsg('');
if (!email) {
setErrorMsg('Veuillez renseigner votre adresse e-mail avant d\'utiliser la clé d\'accès.');
return;
}
const hasPasskey = localStorage.getItem(`passkey_${email}`);
if (hasPasskey === 'registered') {
setLoading(true);
await new Promise(res => setTimeout(res, 1000)); // Simulate WebAuthn UI
// To actually log them in without password, we'd need a custom auth flow.
// Since it's a demo, we will just show an error indicating this is a demo,
// or we can fake login by storing a token, but Supabase requires password or magic link.
setErrorMsg("Simulation : La clé d'accès est reconnue ! (L'authentification complète nécessite un backend WebAuthn).");
setLoading(false);
} else {
setErrorMsg(`Aucune clé d'accès trouvée pour ${email} sur cet appareil.`);
}
};
const userData = user?.user_metadata || {};
return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: step === 1 ? '#fff' : '#f5f6f8' }}>
{step !== 1 && <SiteHeader regionSlug={regionSlug} />}
<main className={`flex-1 ${step !== 1 ? 'py-8 px-4 sm:px-6' : ''}`}>
{/* ======================= */}
{/* ETAPE 0 : PAGE ACCUEIL COMPTE (IMG 1) */}
{/* ======================= */}
{step === 0 && (
<div className="max-w-[850px] mx-auto animate-fadeIn">
<div className="mb-6 flex items-center text-[13px] text-gray-500 font-medium">
<span className="material-icons text-[18px] mr-1" style={{ color: theme.primary }}>home</span>
<span className="mx-2">&gt;</span>
<span>Connexion</span>
</div>
<div className="bg-white rounded-xl shadow-sm p-8 sm:p-12 text-center border border-gray-100 max-w-[700px] mx-auto">
<h1 className="text-[22px] sm:text-[26px] font-bold text-gray-800 mb-2" style={{ color: theme.primary }}>
Gagnez du temps avec votre compte client !
</h1>
<p className="text-gray-500 text-[14.5px] mb-8">
Toutes vos informations seront pré-remplies
</p>
<div className="space-y-4 text-left max-w-[480px] mx-auto mb-10">
<div className="flex items-start gap-3">
<span className="material-icons mt-0.5" style={{ color: theme.primary }}>confirmation_number</span>
<p className="text-[14.5px] text-gray-700"><strong className="font-bold">Retrouvez</strong> toutes vos commandes TER au même endroit</p>
</div>
<div className="flex items-start gap-3">
<span className="material-icons mt-0.5" style={{ color: theme.primary }}>storefront</span>
<p className="text-[14.5px] text-gray-700"><strong className="font-bold">Enregistrez</strong> vos gares et voyageurs favoris</p>
</div>
<div className="flex items-start gap-3">
<span className="material-icons mt-0.5" style={{ color: theme.primary }}>credit_card</span>
<p className="text-[14.5px] text-gray-700"><strong className="font-bold">Ajoutez</strong> votre carte bancaire et réglez en 1 clic</p>
</div>
<div className="flex items-start gap-3">
<span className="material-icons mt-0.5" style={{ color: theme.primary }}>chat</span>
<p className="text-[14.5px] text-gray-700"><strong className="font-bold">Consultez</strong> l'évolution du traitement de vos réclamations</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
onClick={() => { setIsSignUp(false); setStep(1); }}
className="w-full sm:w-auto px-8 py-3 rounded-md text-white font-semibold text-[15px] transition-opacity hover:opacity-90 shadow-sm"
style={{ backgroundColor: theme.primary }}
>
Me connecter
</button>
<button
onClick={() => { setIsSignUp(true); setStep(1); }}
className="w-full sm:w-auto px-8 py-3 rounded-md bg-white border border-gray-300 font-semibold text-[15px] hover:bg-gray-50 transition-colors shadow-sm"
style={{ color: theme.primary }}
>
Créer mon compte
</button>
</div>
</div>
</div>
)}
{/* ======================= */}
{/* ETAPE 1 : LOGIN SNCF (IMG 2) */}
{/* ======================= */}
{step === 1 && (
<div className="min-h-screen bg-[#faf9f8] flex items-center justify-center p-4 animate-fadeIn">
<div className="bg-white max-w-[900px] w-full min-h-[550px] shadow-2xl flex flex-col md:flex-row rounded-sm overflow-hidden">
{/* Left red side */}
<div className="w-full md:w-[40%] bg-[#961533] text-white p-8 sm:p-12 flex flex-col justify-center relative min-h-[300px]">
<div className="text-[14px] font-semibold mb-4 opacity-90 tracking-wide">
Mon Identifiant SNCF
</div>
<h2 className="text-[24px] sm:text-[28px] font-bold leading-snug mb-12">
Une solution d'identification par<br/>SNCF Voyageurs
</h2>
{/* Minimalist drawing imitation */}
<div className="relative w-full h-[220px] mt-auto hidden sm:block">
<div className="absolute bottom-0 w-full h-[2px] bg-white/20"></div>
{/* Character drawing (simplified SVG representation) */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[180px] h-[180px]">
<svg viewBox="0 0 200 200" className="w-full h-full" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="30" y="50" width="40" height="120" fill="#fbd34c" stroke="#111" strokeWidth="4"/>
<rect x="25" y="45" width="50" height="30" fill="#fbd34c" stroke="#111" strokeWidth="4"/>
<path d="M90 170 h70 v-60 h-70 v60" fill="#148369" stroke="#111" strokeWidth="4"/>
<path d="M110 80 c0-15 20-15 20 0 v20 h-20 v-20" fill="#fba184" stroke="#111" strokeWidth="4"/>
<rect x="90" y="100" width="60" height="50" fill="#4688f2" stroke="#111" strokeWidth="4"/>
<rect x="80" y="120" width="55" height="35" fill="#e5e5e5" stroke="#111" strokeWidth="4"/>
<circle cx="107" cy="137" r="4" fill="#111"/>
<path d="M100 150 h40 l10 40 h-20 l-5-20 h-10 l-5 20 h-20 l10-40" fill="#d2f2a6" stroke="#111" strokeWidth="4"/>
<rect x="95" y="185" width="20" height="10" fill="#fff" stroke="#111" strokeWidth="4"/>
<rect x="125" y="185" width="20" height="10" fill="#fff" stroke="#111" strokeWidth="4"/>
<path d="M115 88 q5 5 10 0" stroke="#111" strokeWidth="3" fill="none"/>
<circle cx="115" cy="82" r="2" fill="#111"/>
<circle cx="125" cy="82" r="2" fill="#111"/>
</svg>
</div>
</div>
</div>
{/* Right white side */}
<div className="w-full md:w-[60%] p-8 sm:p-14 flex flex-col justify-center">
<div className="flex items-center justify-center gap-3 mb-8">
<div className="bg-[#961533] text-white text-[11px] font-bold px-2 py-1 rounded-sm text-center leading-tight">SNCF<br/><span className="text-[6px] font-normal">VOYAGEURS</span></div>
<span className="text-[18px] font-bold text-[#961533]">Mon Identifiant SNCF</span>
</div>
<div className="text-center mb-6">
<h1 className="text-[22px] font-bold text-gray-900 mb-2">Bienvenue</h1>
<p className="text-gray-600 text-[14px]">
Connectez-vous pour accéder aux espaces clients des sites et applications SNCF et ses partenaires<sup className="text-[10px]">(1)</sup>.
</p>
</div>
{errorMsg && (
<div className="mb-4 text-[13px] text-white bg-[#c00] p-3 rounded-md text-center font-medium">
{errorMsg}
</div>
)}
{successMsg && (
<div className="mb-4 text-[13px] text-white bg-[#006e39] p-3 rounded-md text-center font-medium">
{successMsg}
</div>
)}
<form onSubmit={handleLoginOrSignup} className="space-y-4 max-w-[400px] mx-auto w-full">
{isSignUp && (
<div className="flex gap-3">
<input
type="text"
value={prenom}
onChange={(e) => setPrenom(e.target.value)}
placeholder="Prénom"
className="w-1/2 border border-gray-300 rounded-full px-5 py-3 text-[14.5px] outline-none focus:border-[#961533] transition-colors"
required
/>
<input
type="text"
value={nom}
onChange={(e) => setNom(e.target.value)}
placeholder="Nom"
className="w-1/2 border border-gray-300 rounded-full px-5 py-3 text-[14.5px] outline-none focus:border-[#961533] transition-colors"
required
/>
</div>
)}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse e-mail (exemple@mail.com)"
className="w-full border border-gray-300 rounded-full px-5 py-3 text-[14.5px] outline-none focus:border-[#961533] transition-colors"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mot de passe"
className="w-full border border-gray-300 rounded-full px-5 py-3 text-[14.5px] outline-none focus:border-[#961533] transition-colors"
required
minLength={6}
/>
<div className="text-left flex justify-between items-center pt-1">
<button
type="button"
onClick={() => { setIsSignUp(!isSignUp); setErrorMsg(''); setSuccessMsg(''); }}
className="text-[#961533] text-[13px] hover:underline font-medium"
>
{isSignUp ? "Déjà un compte ? Se connecter" : "Créer un nouveau compte ?"}
</button>
{!isSignUp && (
<button
type="button"
onClick={handlePasskeyLogin}
className="text-gray-500 hover:text-[#961533] flex items-center gap-1 text-[13px] transition-colors font-medium"
>
<span className="material-icons text-[16px]">fingerprint</span>
Clé d'accès
</button>
)}
</div>
<div className="text-center space-y-3 pt-4 border-t border-gray-100 mt-4">
<p className="text-[12.5px] text-gray-500">En cliquant sur "{isSignUp ? "Créer mon compte" : "Se connecter"}", j'accepte :</p>
<p className="text-[12.5px] text-[#961533] hover:underline cursor-pointer flex items-center justify-center gap-1">
Les conditions générales d'utilisation de Mon Identifiant SNCF <span className="material-icons text-[14px]">open_in_new</span>
</p>
<p className="text-[12.5px] text-[#961533] hover:underline cursor-pointer flex items-center justify-center gap-1">
La politique de gestion des Données à Caractère Personnel de Mon Identifiant SNCF <span className="material-icons text-[14px]">open_in_new</span>
</p>
</div>
<div className="flex items-center justify-center gap-4 pt-4">
<button
type="button"
onClick={() => setStep(0)}
className="px-8 py-2.5 border border-[#961533] text-[#961533] rounded-full font-semibold hover:bg-[#961533]/5 transition-colors text-[15px]"
>
Annuler
</button>
<button
type="submit"
disabled={loading}
className="px-8 py-2.5 bg-[#961533] text-white rounded-full font-semibold hover:bg-[#7a1129] transition-colors disabled:opacity-50 text-[15px] min-w-[150px]"
>
{loading ? 'Chargement...' : (isSignUp ? 'Créer' : 'Se connecter')}
</button>
</div>
<div className="text-center pt-6">
<button type="button" className="text-[11.5px] text-[#961533] hover:underline flex items-center justify-center gap-1 mx-auto">
(1) Afficher les différents partenaires <span className="material-icons text-[14px]">south</span>
</button>
</div>
</form>
</div>
</div>
<div className="fixed bottom-4 w-full px-8 flex justify-between items-center text-[11px] text-gray-500 opacity-80 max-w-[900px]">
<span className="cursor-pointer hover:underline">Accessibilité : partiellement conforme <span className="material-icons text-[11px]">open_in_new</span></span>
<span className="flex items-center gap-1 cursor-pointer hover:text-black">Langue : 🇫🇷 Français <span className="material-icons text-[12px]">arrow_drop_down</span></span>
</div>
</div>
)}
{/* ======================= */}
{/* ETAPE 2 : DASHBOARD (IMG 3) */}
{/* ======================= */}
{step === 2 && (
<div className="max-w-[1100px] mx-auto px-4 sm:px-6 flex flex-col md:flex-row gap-8 mt-6 animate-fadeIn">
{/* Sidebar */}
<div className="w-full md:w-[280px] bg-[#006e39] text-white rounded-md overflow-hidden flex-shrink-0" style={{ backgroundColor: theme.primary }}>
<div className="p-4 bg-[#00522a] font-bold text-[15px]" style={{ backgroundColor: 'rgba(0,0,0,0.15)' }}>
Bienvenue {userData?.prenom || 'Client'}
</div>
<ul className="flex flex-col text-[14.5px]">
<li className="bg-[#e4ebce] text-gray-900 font-semibold p-4 border-l-4 border-[#006e39]" style={{ backgroundColor: '#f2f5e9', borderLeftColor: theme.primary }}>
Mes informations personnelles
</li>
<li className="p-4 hover:bg-black/10 cursor-pointer border-b border-white/10 font-medium transition-colors">
Mes billets
</li>
<li className="p-4 hover:bg-black/10 cursor-pointer border-b border-white/10 font-medium transition-colors">
Mes abonnements et cartes de réduction
</li>
<li className="p-4 hover:bg-black/10 cursor-pointer border-b border-white/10 font-medium transition-colors">
Mes voyageurs favoris
</li>
<li className="p-4 hover:bg-black/10 cursor-pointer border-b border-white/10 font-medium transition-colors">
Mes moyens de paiement
</li>
<li className="p-4 hover:bg-black/10 cursor-pointer border-b border-white/10 font-medium transition-colors">
Mes gares favorites
</li>
<li className="p-4 hover:bg-black/10 cursor-pointer border-b border-white/10 font-medium transition-colors">
Mes demandes
</li>
<li className="p-4 hover:bg-black/10 cursor-pointer border-b border-white/10 font-medium transition-colors">
Mes préférences
</li>
<li
onClick={handleLogout}
className="p-4 hover:bg-[#c00] cursor-pointer font-medium mt-auto border-t border-white/20 transition-colors flex items-center gap-2"
>
<span className="material-icons text-[18px]">logout</span>
Me déconnecter
</li>
</ul>
</div>
{/* Main Content */}
<div className="flex-1 bg-white p-0 sm:p-2">
<div className="flex justify-between items-center mb-8">
<h1 className="text-[28px] font-bold text-gray-900">Mes informations personnelles</h1>
{successMsg && (
<span className="text-sm text-green-700 bg-green-50 px-3 py-1 rounded font-medium border border-green-200">
{successMsg}
</span>
)}
{errorMsg && (
<span className="text-sm text-red-700 bg-red-50 px-3 py-1 rounded font-medium border border-red-200">
{errorMsg}
</span>
)}
</div>
<div className="space-y-12">
{/* Coordonnées */}
<section>
<div className="flex justify-between items-center border-b pb-2 mb-6 border-gray-300">
<h2 className="text-[18px] font-bold text-gray-800">Mes coordonnées personnelles</h2>
<button className="text-gray-600 hover:text-black flex items-center gap-1 text-[14px] font-medium transition-colors">
<span className="material-icons text-[16px]" style={{ color: theme.primary }}>edit</span>
Modifier
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div>
<p className="text-[12px] text-gray-500 mb-1">Nom</p>
<p className="text-[15px] text-gray-900 font-medium">{userData?.nom || '-'}</p>
</div>
<div>
<p className="text-[12px] text-gray-500 mb-1">Prénom</p>
<p className="text-[15px] text-gray-900 font-medium">{userData?.prenom || '-'}</p>
</div>
<div>
<p className="text-[12px] text-gray-500 mb-1">Date de naissance</p>
<p className="text-[15px] text-gray-900 font-medium">{userData?.date_naissance || '-'}</p>
</div>
</div>
<div>
<p className="text-[12px] text-gray-500 mb-1">Téléphone mobile (optionnel)</p>
<p className="text-[15px] text-gray-900 font-medium">{userData?.telephone || '-'}</p>
</div>
</section>
{/* Identifiants */}
<section>
<div className="flex justify-between items-center border-b pb-2 mb-6 border-gray-300">
<h2 className="text-[18px] font-bold text-gray-800">Mes identifiants</h2>
</div>
<div className="space-y-6">
<div className="flex justify-between items-center border-b border-gray-100 pb-4">
<div>
<p className="text-[12px] text-gray-500 mb-1">E-mail</p>
<p className="text-[15px] text-gray-900 font-medium">{user?.email}</p>
</div>
<button className="text-gray-600 hover:text-black flex items-center gap-1 text-[14px] font-medium transition-colors">
<span className="material-icons text-[16px]" style={{ color: theme.primary }}>edit</span>
Modifier
</button>
</div>
<div className="flex justify-between items-center border-b border-gray-100 pb-4">
<div>
<p className="text-[12px] text-gray-500 mb-1">Mot de passe</p>
<p className="text-[15px] text-gray-900 font-medium">••••••••••</p>
</div>
<button className="text-gray-600 hover:text-black flex items-center gap-1 text-[14px] font-medium transition-colors">
<span className="material-icons text-[16px]" style={{ color: theme.primary }}>edit</span>
Modifier
</button>
</div>
{/* OPTION CREATION CLE D'ACCES (PASSKEY) */}
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-lg border border-gray-200">
<div>
<p className="text-[14px] font-bold text-gray-900 mb-1 flex items-center gap-2">
<span className="material-icons text-[18px]">fingerprint</span>
Clé d'accès (Passkey)
</p>
<p className="text-[13px] text-gray-600">Connectez-vous avec votre empreinte digitale ou Face ID au lieu d'un mot de passe.</p>
</div>
<button
onClick={handlePasskeyRegister}
disabled={loading}
className="text-white px-4 py-2 rounded font-medium text-[13px] transition-colors hover:opacity-90 whitespace-nowrap disabled:opacity-50"
style={{ backgroundColor: theme.primary }}
>
{loading ? 'Création...' : 'Créer une clé'}
</button>
</div>
</div>
</section>
{/* Carte de réduction */}
<section>
<div className="flex justify-between items-center border-b pb-2 mb-6 border-gray-300">
<h2 className="text-[18px] font-bold text-gray-800">Ma carte de réduction</h2>
</div>
<div className="flex justify-between items-center">
<p className="text-[15px] text-gray-600">Sélectionner une carte de réduction (optionnel)</p>
<button className="text-gray-600 hover:text-black flex items-center gap-1 text-[14px] font-medium transition-colors">
<span className="material-icons text-[16px]" style={{ color: theme.primary }}>edit</span>
Modifier
</button>
</div>
</section>
{/* RGPD Disclaimer */}
<div className="pt-8 text-[11px] text-gray-500 leading-relaxed border-t border-gray-200 text-justify">
* Conformément à la réglementation applicable relative au transfert, à la collecte, la conservation et la sécurité des données personnelles, et en particulier, la loi 78-17 du 6 janvier 1978 dite « informatique et libertés » modifiée et le règlement (UE) 2016/679 du Parlement européen et du Conseil du 27 avril 2016 applicable depuis le 25 mai 2018, toute personne, justifiant de son identité, peut exercer ses droits d'accès, de rectification, d'effacement et d'opposition, ainsi que ses droits à la limitation du traitement, droit à la portabilité de ses données, et droit de ne pas faire l'objet d'une décision individuelle automatisée (y compris le profilage), en adressant une demande à SNCF Voyageurs SA via le formulaire dédié.
</div>
</div>
</div>
</div>
)}
</main>
{step !== 1 && <SiteFooter regionSlug={regionSlug} />}
</div>
);
}
+5 -1
View File
@@ -445,7 +445,11 @@ export default function OffersPageClient({
// Extract matériel roulant image from composition_train BDD // Extract matériel roulant image from composition_train BDD
const composition = Array.isArray(item.composition_train) ? item.composition_train : []; const composition = Array.isArray(item.composition_train) ? item.composition_train : [];
const firstMat = composition.length > 0 ? composition[0] : null; const firstMat = composition.length > 0 ? composition[0] : null;
const matImageUrl = firstMat?.materiel_image_url || 'https://supabase-ferrovia.mrpatator.fr/storage/v1/object/public/materiel-roulant/images/1789909846752_regiolis_firsttosecond_bluegreenmobigo_4c__1_.png';
const diffDays = Math.ceil((new Date(targetIsoDate).getTime() - new Date().setHours(0,0,0,0)) / (1000 * 60 * 60 * 24));
const showMateriel = diffDays <= 1;
const matImageUrl = showMateriel ? (firstMat?.materiel_image_url || 'https://supabase-ferrovia.mrpatator.fr/storage/v1/object/public/materiel-roulant/images/1789909846752_regiolis_firsttosecond_bluegreenmobigo_4c__1_.png') : undefined;
const matNameText = firstMat ? `Un train "${firstMat.materiel_nom || 'Régiolis'}" ${firstMat.materiel_nom_technique ? `(${firstMat.materiel_nom_technique})` : ''}` : (item.modele_train || 'Un train "AGC"'); const matNameText = firstMat ? `Un train "${firstMat.materiel_nom || 'Régiolis'}" ${firstMat.materiel_nom_technique ? `(${firstMat.materiel_nom_technique})` : ''}` : (item.modele_train || 'Un train "AGC"');
mappedList.push({ mappedList.push({
+30 -3
View File
@@ -2,9 +2,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import HeaderMenu, { MenuItem } from '@/components/HeaderMenu'; import HeaderMenu, { MenuItem } from '@/components/HeaderMenu';
import { getRegionTheme } from '@/lib/regionTheme'; import { getRegionTheme } from '@/lib/regionTheme';
import SmartSearchBar from '@/components/SmartSearchBar';
interface SiteHeaderProps { interface SiteHeaderProps {
regionSlug?: string; regionSlug?: string;
logoUrl?: string; logoUrl?: string;
@@ -39,7 +42,9 @@ export default function SiteHeader({
activeTab, activeTab,
onTabSelect, onTabSelect,
}: SiteHeaderProps) { }: SiteHeaderProps) {
const router = useRouter();
const theme = getRegionTheme(regionSlug); const theme = getRegionTheme(regionSlug);
const [showSmartSearch, setShowSmartSearch] = useState(false);
const [showOrderModal, setShowOrderModal] = useState(false); const [showOrderModal, setShowOrderModal] = useState(false);
const [showCartDrawer, setShowCartDrawer] = useState(false); const [showCartDrawer, setShowCartDrawer] = useState(false);
const [showAccountModal, setShowAccountModal] = useState(false); const [showAccountModal, setShowAccountModal] = useState(false);
@@ -100,14 +105,29 @@ export default function SiteHeader({
</Link> </Link>
</div> </div>
{/* Right: Actions (Commande, Panier, Compte - Img 2) */} {/* Right: Actions (Rechercher, Commande, Panier, Compte - Img 2) */}
<div className="flex items-center text-[11px] font-medium text-gray-700"> <div className="flex items-center text-[11px] font-medium text-gray-700">
{/* Rechercher */}
<button
type="button"
onClick={() => setShowSmartSearch(true)}
className="flex flex-col items-center px-2 sm:px-3.5 group cursor-pointer hover:opacity-80 transition-opacity"
title="Recherche intelligente d'horaires, trains, gares..."
>
<span className="material-icons text-[21px] mb-0.5" style={{ color: theme.primary }}>
search
</span>
<span>Rechercher</span>
</button>
<div className="w-[1px] h-6 sm:h-7 bg-gray-200"></div>
{/* Commande */} {/* Commande */}
<button <button
type="button" type="button"
onClick={() => setShowOrderModal(true)} onClick={() => setShowOrderModal(true)}
className="hidden md:flex flex-col items-center px-4 group cursor-pointer hover:opacity-80 transition-opacity" className="hidden md:flex flex-col items-center px-3.5 group cursor-pointer hover:opacity-80 transition-opacity"
> >
<span className="material-icons text-[20px] mb-0.5" style={{ color: theme.primary }}> <span className="material-icons text-[20px] mb-0.5" style={{ color: theme.primary }}>
edit_note edit_note
@@ -134,7 +154,7 @@ export default function SiteHeader({
{/* Compte */} {/* Compte */}
<button <button
type="button" type="button"
onClick={() => setShowAccountModal(true)} onClick={() => router.push(regionSlug ? `/${regionSlug}/compte` : '/compte')}
className="flex flex-col items-center px-2 sm:px-4 group cursor-pointer hover:opacity-80 transition-opacity" 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 }}> <span className="material-icons text-[22px] sm:text-[22px]" style={{ color: theme.primary }}>
@@ -292,6 +312,13 @@ export default function SiteHeader({
</div> </div>
)} )}
{/* SMART SEARCH BAR DRAWER (Slide in right to left) */}
<SmartSearchBar
isOpen={showSmartSearch}
onClose={() => setShowSmartSearch(false)}
regionSlug={regionSlug}
/>
</header> </header>
); );
} }
+250
View File
@@ -0,0 +1,250 @@
"use client";
import React, { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { getRegionTheme } from '@/lib/regionTheme';
interface SmartSearchBarProps {
isOpen: boolean;
onClose: () => void;
regionSlug?: string;
}
export default function SmartSearchBar({
isOpen,
onClose,
regionSlug = 'bourgogne-franche-comte'
}: SmartSearchBarProps) {
const router = useRouter();
const theme = getRegionTheme(regionSlug);
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
// Focus input on open
useEffect(() => {
if (isOpen) {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
setQuery('');
setResult(null);
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// Handle ESC key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
setResult(null);
try {
const response = await fetch('/api/search/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
regionSlug,
currentDate: new Date().toISOString()
})
});
const data = await response.json();
setResult(data);
if (data.type === 'redirect' && data.url) {
// Wait a short delay to let the user read the success message
setTimeout(() => {
onClose();
router.push(data.url);
}, 1500);
}
} catch (err) {
setResult({ type: 'error', message: "Une erreur est survenue lors de la recherche." });
} finally {
setLoading(false);
}
};
const handleOptionClick = (url: string) => {
onClose();
router.push(url);
};
const getQuickLinks = () => [
{ title: "Fiches Horaires", query: "Fiches horaires de la région", icon: "description" },
{ title: "Info Trafic", query: "Informations sur le trafic", icon: "traffic" },
{ title: "Gare de Dijon", query: "Gare de Dijon", icon: "place" },
{ title: "Dijon à Besançon", query: "Itinéraire de Dijon à Besançon", icon: "alt_route" }
];
return (
<div
className={`fixed inset-0 z-50 transition-opacity duration-300 ${
isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
>
<div
className="absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-300"
onClick={onClose}
/>
<div
className={`absolute top-0 right-0 bottom-0 w-full max-w-[580px] bg-white shadow-2xl flex flex-col transform transition-transform duration-300 ease-out z-10 ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="flex-1 flex flex-col">
<div className="p-4 sm:p-5 border-b border-gray-100 bg-[#fbfbfb]">
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-[14px] font-bold text-gray-800 uppercase tracking-wide flex items-center gap-1.5">
<span className="material-icons text-[20px]" style={{ color: theme.primary }}>
auto_awesome
</span>
<span>Recherche IA</span>
</span>
<button
type="button"
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-200 transition-colors cursor-pointer"
aria-label="Fermer la recherche"
>
<span className="material-icons text-[22px]">close</span>
</button>
</div>
<form onSubmit={handleFormSubmit} className="relative">
<div className="relative flex items-center">
<span className="material-icons absolute left-4 text-[22px] text-gray-400">
search
</span>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Posez votre question (ex: train 891811 pour demain...)"
className="w-full pl-12 pr-12 py-4 bg-white border border-gray-300 rounded-xl text-[15px] text-gray-900 placeholder-gray-400 shadow-inner focus:outline-none focus:ring-2 focus:border-transparent transition-all"
style={{ accentColor: theme.primary }}
disabled={loading || (result?.type === 'redirect')}
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="absolute right-2 p-2 rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-600 disabled:opacity-50"
>
<span className="material-icons text-[20px]">
{loading ? 'hourglass_empty' : 'send'}
</span>
</button>
</div>
</form>
</div>
<div className="flex-1 overflow-y-auto p-4 sm:p-5 bg-gray-50">
{loading && (
<div className="flex flex-col items-center justify-center py-12 text-gray-500 space-y-4">
<div className="w-10 h-10 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin" style={{ borderTopColor: theme.primary }}></div>
<p className="text-[14px] font-medium animate-pulse">L'IA analyse votre demande...</p>
</div>
)}
{!loading && result && (
<div className="space-y-4 mb-6">
<div className={`p-4 rounded-xl ${
result.type === 'error' ? 'bg-red-50 border border-red-200 text-red-700' :
result.type === 'redirect' ? 'bg-green-50 border border-green-200 text-green-700' :
result.type === 'options' ? 'bg-blue-50 border border-blue-200 text-blue-800' :
'bg-gray-100 border border-gray-200 text-gray-700'
}`}>
<div className="flex items-start gap-3">
<span className="material-icons mt-0.5">
{result.type === 'error' ? 'error_outline' :
result.type === 'redirect' ? 'check_circle' : 'info'}
</span>
<p className="text-[15px] font-medium leading-relaxed">
{result.message}
</p>
</div>
</div>
{result.type === 'options' && result.options && (
<div className="space-y-2 mt-2">
{result.options.map((opt: any, idx: number) => (
<button
key={idx}
onClick={() => handleOptionClick(opt.url)}
className="w-full text-left p-3.5 rounded-xl border border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-50 transition-all flex items-center gap-3 group shadow-sm cursor-pointer"
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 transition-transform group-hover:scale-105" style={{ backgroundColor: theme.primaryLight || '#e0f2fe' }}>
<span className="material-icons text-[18px]" style={{ color: theme.primary || '#0284c7' }}>
{opt.icon || 'place'}
</span>
</div>
<span className="font-semibold text-gray-800 group-hover:text-blue-700">{opt.title}</span>
<span className="material-icons ml-auto text-gray-300 group-hover:text-blue-500">chevron_right</span>
</button>
))}
</div>
)}
</div>
)}
{!loading && !result && (
<div>
<p className="text-[13px] font-bold text-gray-400 uppercase tracking-wider mb-3 px-1">Exemples de requêtes</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{getQuickLinks().map((link, idx) => (
<button
key={idx}
onClick={() => { setQuery(link.query); setTimeout(() => handleFormSubmit({ preventDefault: () => {} } as React.FormEvent), 0); }}
className="text-left p-4 rounded-xl border border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm transition-all flex items-center gap-3 group cursor-pointer"
>
<div className="w-8 h-8 rounded-lg bg-gray-50 flex items-center justify-center text-gray-500 group-hover:text-blue-600 transition-colors" style={{ color: theme.primary }}>
<span className="material-icons text-[18px]">{link.icon}</span>
</div>
<span className="text-[14px] font-medium text-gray-700">{link.title}</span>
</button>
))}
</div>
<div className="mt-8 p-4 bg-blue-50 rounded-xl border border-blue-100 flex items-start gap-3">
<span className="material-icons text-blue-500 mt-0.5">tips_and_updates</span>
<div className="text-sm text-blue-800">
<p className="font-bold mb-1">Recherche Intelligente par IA</p>
<p className="opacity-90 leading-relaxed">
Tapez naturellement ce que vous cherchez. Exemples :<br/>
• "Est-ce que le train 891811 circule demain ?"<br/>
• "Quels sont les fiches horaires de la gare de Nevers ?"<br/>
• "Itinéraires de Dijon à Seurre"
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -755,8 +755,10 @@ export default function TimetableSearchForm({
const isSelected = dayNum === selectedDayNumber; const isSelected = dayNum === selectedDayNumber;
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const sevenDaysAgo = new Date(today);
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const dayDate = new Date(year, month, dayNum, 0, 0, 0, 0); const dayDate = new Date(year, month, dayNum, 0, 0, 0, 0);
const isPastDate = dayDate < today; const isPastDate = dayDate < sevenDaysAgo;
return ( return (
<button <button
+590
View File
@@ -0,0 +1,590 @@
"use client";
import React, { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { getRegionTheme } from '@/lib/regionTheme';
import SiteHeader from '@/components/SiteHeader';
import SiteFooter from '@/components/SiteFooter';
interface StopItem {
gare_id?: string;
gare_nom: string;
heure_prevue: string;
heure_estimee?: string;
voie?: string;
duree_arret?: string;
is_depart?: boolean;
is_arrivee?: boolean;
}
interface TrainDetailsClientProps {
trainNumber: string;
regionSlug?: string;
dateStr?: string;
}
export default function TrainDetailsClient({
trainNumber,
regionSlug = 'bourgogne-franche-comte',
dateStr,
}: TrainDetailsClientProps) {
const theme = getRegionTheme(regionSlug);
const [menuStructure, setMenuStructure] = useState<any>(null);
const [stops, setStops] = useState<StopItem[]>([]);
const [delayMinutes, setDelayMinutes] = useState<number>(0);
const [destination, setDestination] = useState<string>('Terminus');
const [operatorInfo, setOperatorInfo] = useState<string>('SNCF Voyageurs');
const [trainType, setTrainType] = useState<string>('TER');
const [materielInfo, setMaterielInfo] = useState<{ nom: string, nom_technique?: string, imageUrl: string, places: number, voitures: number } | null>(null);
const [perturbationInfo, setPerturbationInfo] = useState<{ type: string, delayMinutes: number, message: string } | null>(null);
const currentDateDisplay = dateStr || new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
// Format date for the info card (e.g., "24 sept. 2026")
const formattedDateLong = (() => {
const parts = currentDateDisplay.split('/');
if (parts.length === 3) {
const months = ['janv.', 'fév.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'];
const day = parseInt(parts[0], 10);
const monthIdx = parseInt(parts[1], 10) - 1;
return `${day} ${months[monthIdx] || parts[1]} ${parts[2]}`;
}
return currentDateDisplay;
})();
useEffect(() => {
const fetchTrainDetails = async () => {
try {
const cleanSlug = regionSlug || 'bourgogne-franche-comte';
const { data: rData } = await supabase
.from('regions')
.select('id, slug, operator_name')
.or(`slug.eq.${cleanSlug},code.eq.${cleanSlug}`);
const rIds = rData ? rData.map((r: any) => r.id) : [];
if (rData && rData[0] && rData[0].operator_name) {
setOperatorInfo(rData[0].operator_name);
}
// 1. Fetch menu
let queryMenu = supabase.from('menus_regionaux').select('structure').eq('actif', true);
if (rIds.length > 0) {
queryMenu = queryMenu.in('region_id', rIds);
}
const { data: mData } = await queryMenu.limit(1);
if (mData && mData.length > 0 && mData[0].structure) {
setMenuStructure(mData[0].structure);
}
// 2. Fetch train details
const { data: hData } = await supabase
.from('horaires')
.select('*')
.eq('numero_train', trainNumber)
.eq('actif', true)
.is('deleted_at', null)
.limit(1);
if (hData && hData.length > 0) {
const h = hData[0];
setTrainType(h.type_train?.toUpperCase() || 'TER');
if (h.gare_arrivee_nom) {
setDestination(h.gare_arrivee_nom);
} else if (h.gare_arrivee_id) {
const { data: gArr } = await supabase
.from('gares')
.select('nom')
.eq('id', h.gare_arrivee_id)
.single();
if (gArr) setDestination(gArr.nom);
}
// Fetch perturbations
const parts = currentDateDisplay.split('/');
const targetIsoDate = parts.length === 3 ? `${parts[2]}-${parts[1]}-${parts[0]}` : new Date().toISOString().split('T')[0];
const { data: pertData } = await supabase.from('perturbations').select('*').eq('actif', true);
let currentDelay = 0;
let pArrets: any[] = [];
if (pertData) {
const matchingPert = pertData.find((p: any) => {
const matchTrain = p.horaire_id === h.id || p.horaire_id === h.numero_train;
if (!matchTrain) return false;
const pStart = (p.date_debut || '').slice(0, 10);
const pEnd = (p.date_fin || '').slice(0, 10);
if (pStart && pEnd && pStart <= targetIsoDate && targetIsoDate <= pEnd) return true;
if (p.jours_perturbation && Array.isArray(p.jours_perturbation)) {
return p.jours_perturbation.some((jp: any) => {
const d = (jp.date || jp.dateDebut || '').slice(0, 10);
return d === targetIsoDate;
});
}
return !pStart && !pEnd && (!p.jours_perturbation || p.jours_perturbation.length === 0);
});
if (matchingPert) {
pArrets = Array.isArray(matchingPert.perturbations_arrets) ? matchingPert.perturbations_arrets : [];
// Find max delay in perturbations_arrets
let maxArretsDelay = 0;
pArrets.forEach(pa => {
if (pa.retard_cumule > maxArretsDelay) maxArretsDelay = pa.retard_cumule;
if (pa.retard_entrant > maxArretsDelay) maxArretsDelay = pa.retard_entrant;
});
currentDelay = maxArretsDelay > 0 ? maxArretsDelay : (matchingPert.duree_retard_minutes || 0);
setPerturbationInfo({
type: matchingPert.type_perturbation || 'Retard',
delayMinutes: currentDelay,
message: matchingPert.cause_details || matchingPert.notes || '',
});
setDelayMinutes(currentDelay);
}
}
// Fetch materiel roulant
if (h.composition_train && Array.isArray(h.composition_train) && h.composition_train.length > 0) {
const firstMat = h.composition_train[0];
if (firstMat.materiel_image_url) {
setMaterielInfo({
nom: firstMat.materiel_nom || 'Train',
nom_technique: firstMat.materiel_nom_technique,
imageUrl: firstMat.materiel_image_url,
places: firstMat.materiel_nb_places || 163,
voitures: firstMat.materiel_voitures || 3,
});
}
}
if (h.gares_desservies) {
let parsedStops: any[] = [];
if (typeof h.gares_desservies === 'string') {
try { parsedStops = JSON.parse(h.gares_desservies); } catch (e) { parsedStops = []; }
} else if (Array.isArray(h.gares_desservies)) {
parsedStops = h.gares_desservies;
}
if (parsedStops.length > 0) {
// Fetch types for gares to apply quai logic
const stopGareIds = parsedStops.map(s => s.gare_id).filter(Boolean);
let garesTypeMap: Record<string, string> = {};
if (stopGareIds.length > 0) {
const { data: gData } = await supabase.from('gares').select('id, type').in('id', stopGareIds);
if (gData) {
gData.forEach(g => {
garesTypeMap[g.id] = g.type || 'ville';
});
}
}
const now = new Date();
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const resolved: StopItem[] = parsedStops.map((s, idx) => {
const prev = s.heure_depart || s.heure_arrivee || '12:00';
let est = prev;
// Get delay specifically for this stop
const stopPert = pArrets.find(pa => pa.gare_nom === s.gare_nom || pa.gare_id === s.gare_id);
const stopDelay = stopPert && stopPert.retard_cumule !== undefined ? stopPert.retard_cumule : currentDelay;
if (stopDelay > 0) {
const [hh, mm] = prev.split(':').map(Number);
const totalMin = (hh * 60 + mm + stopDelay) % 1440;
const newH = String(Math.floor(totalMin / 60)).padStart(2, '0');
const newM = String(totalMin % 60).padStart(2, '0');
est = `${newH}:${newM}`;
}
// Logic for displaying Quai/Voie
let displayVoie = undefined;
if (s.quai) {
const type = s.gare_id ? garesTypeMap[s.gare_id] || 'ville' : 'ville';
const [depH, depM] = prev.split(':').map(Number);
const depMinutes = (isNaN(depH) ? 0 : depH) * 60 + (isNaN(depM) ? 0 : depM);
const diffMinutes = depMinutes - currentMinutes;
if (type === 'ville') {
if (diffMinutes <= 30) displayVoie = `Voie ${s.quai}`;
} else if (type === 'interurbaine') {
if (diffMinutes <= 360) displayVoie = `Voie ${s.quai}`;
} else {
if (diffMinutes <= 1440) displayVoie = `Voie ${s.quai}`;
}
}
return {
gare_nom: s.gare_nom || `Arrêt ${idx + 1}`,
heure_prevue: prev.slice(0, 5),
heure_estimee: est.slice(0, 5),
voie: displayVoie,
duree_arret: idx > 0 && idx < parsedStops.length - 1 ? "30 secondes d'arrêt" : undefined,
is_depart: idx === 0,
is_arrivee: idx === parsedStops.length - 1,
};
});
setStops(resolved);
if (!h.gare_arrivee_nom && !h.gare_arrivee_id) {
setDestination(resolved[resolved.length - 1].gare_nom);
}
}
}
}
} catch (err) {
console.error('Error loading train details:', err);
}
};
fetchTrainDetails();
}, [trainNumber, regionSlug]);
const darkenColor = (hex: string, amount: number) => {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.max(0, (num >> 16) - amount);
const g = Math.max(0, ((num >> 8) & 0x00FF) - amount);
const b = Math.max(0, (num & 0x0000FF) - amount);
return `#${(r << 16 | g << 8 | b).toString(16).padStart(6, '0')}`;
};
const darkBg = darkenColor(theme.primary, 60);
const isSuppressed = perturbationInfo?.type?.toLowerCase() === 'suppression';
return (
<div className="min-h-screen text-gray-900 font-sans flex flex-col justify-between" style={{ backgroundColor: '#f0f3f8' }}>
<div>
{/* SITE HEADER */}
<SiteHeader regionSlug={regionSlug} menuStructure={menuStructure} />
{/* ═══════════════════════════════════════════════════════ */}
{/* SECTION 1: Dark theme header area (Image 2 style) */}
{/* ═══════════════════════════════════════════════════════ */}
<div style={{ backgroundColor: darkBg }}>
<div className="py-3.5 px-4" style={{ backgroundColor: darkBg }}>
<div className="max-w-[850px] mx-auto">
<h1 className="text-[14px] sm:text-[15px] font-bold tracking-tight text-white">
Train {trainType} {trainNumber}, {currentDateDisplay}
</h1>
</div>
</div>
<div className="px-4 pb-6 pt-1">
<div className="max-w-[850px] mx-auto">
<div
className="rounded-xl border-[1.5px] border-white/30 px-5 py-4"
style={{ backgroundColor: 'rgba(255,255,255,0.08)' }}
>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-white text-[13px]">
<span className="flex items-center gap-1.5 font-semibold">
<svg viewBox="0 0 24 24" className="w-[16px] h-[16px] fill-current opacity-90" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20.5v.5h2.23l2-2H14l2 2h2v-.5L16.5 19c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-3.58-4-8-4zM7.5 17c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm3.5-7H6V6h5v4zm2 0V6h5v4h-5zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>
</svg>
<span>• {trainType} N°{trainNumber}</span>
</span>
<span className="flex items-center gap-1.5">
<svg viewBox="0 0 24 24" className="w-[14px] h-[14px] fill-current opacity-80" xmlns="http://www.w3.org/2000/svg">
<path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-2 .9-2 2v14a2 2 0 002 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z"/>
</svg>
Départ le {formattedDateLong}
</span>
<span className="flex items-center gap-1.5">
<svg viewBox="0 0 24 24" className="w-[14px] h-[14px] fill-current opacity-80" xmlns="http://www.w3.org/2000/svg">
<path d="M22 10V6a2 2 0 00-2-2H4c-1.1 0-1.99.9-1.99 2v4c1.1 0 1.99.9 1.99 2s-.89 2-2 2v4c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-4c-1.1 0-2-.9-2-2s.9-2 2-2zm-9 7.5h-2v-2h2v2zm0-4.5h-2v-2h2v2zm0-4.5h-2v-2h2v2z"/>
</svg>
Sans réservation
</span>
<span className="flex items-center gap-1.5">
<svg viewBox="0 0 24 24" className="w-[14px] h-[14px] fill-current opacity-80" xmlns="http://www.w3.org/2000/svg">
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
</svg>
{stops.length} arrêts
</span>
<span className="flex items-center gap-1.5">
<svg viewBox="0 0 24 24" className="w-[15px] h-[15px] fill-current opacity-80" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zM5 12c-2.8 0-5 2.2-5 5s2.2 5 5 5 5-2.2 5-5-2.2-5-5-5zm0 8.5c-1.9 0-3.5-1.6-3.5-3.5s1.6-3.5 3.5-3.5 3.5 1.6 3.5 3.5-1.6 3.5-3.5 3.5zm5.8-10l2.4-2.4.8.8c1.3 1.3 3 2.1 5 2.1V9c-1.5 0-2.9-.6-3.9-1.6l-1.7-1.7c-.4-.4-.9-.7-1.5-.7s-1.1.2-1.5.6L7.8 8.4c-.5.5-.8 1.1-.8 1.8 0 .7.3 1.4.8 1.9l3.4 3.4V20h2v-5.5l-2.4-2.4zM19 12c-2.8 0-5 2.2-5 5s2.2 5 5 5 5-2.2 5-5-2.2-5-5-5zm0 8.5c-1.9 0-3.5-1.6-3.5-3.5s1.6-3.5 3.5-3.5 3.5 1.6 3.5 3.5-1.6 3.5-3.5 3.5z"/>
</svg>
Transport de vélo gratuit
</span>
</div>
</div>
</div>
</div>
</div>
{/* ═══════════════════════════════════════════════════════ */}
{/* SECTION 2: MAIN CONTENT with side panel */}
{/* ═══════════════════════════════════════════════════════ */}
<main className="max-w-[850px] mx-auto px-4 pt-5 pb-14">
<div className="flex flex-col lg:flex-row gap-8">
{/* LEFT COLUMN (Train Details & Timeline) */}
<div className="flex-1 lg:max-w-[500px]">
{/* ─── CARD: TRAIN COURT + VISUEL MATERIEL ROULANT ─── */}
<div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100">
<div className="text-[12px] font-normal text-gray-700 mb-2">
Train court
</div>
<div className="flex items-center justify-between gap-1 py-1">
{/* Dynamic Train Illustration or SVG fallback */}
<div className="flex-1 pr-2">
{materielInfo?.imageUrl && (() => {
const parts = currentDateDisplay.split('/');
const searchDate = parts.length === 3 ? new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0])) : new Date();
const diffDays = Math.ceil((searchDate.getTime() - new Date().setHours(0,0,0,0)) / (1000 * 60 * 60 * 24));
return diffDays <= 1;
})() ? (
<div className="w-full flex justify-center py-2">
<img
src={materielInfo.imageUrl}
alt={materielInfo.nom}
className="w-full max-h-[80px] object-contain drop-shadow-md"
/>
</div>
) : (
<div className="overflow-hidden">
<svg viewBox="0 0 460 70" className="w-full h-auto select-none" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="230" cy="67" rx="210" ry="2.5" fill="#cbd5e1" opacity="0.6" />
<line x1="10" y1="65" x2="450" y2="65" stroke="#94a3b8" strokeWidth="1.5" />
<path d="M 18 53 L 24 28 Q 34 20 55 20 L 150 20 L 150 56 L 28 56 Q 20 56 18 53 Z" fill="#f1f5f9" stroke="#475569" strokeWidth="1.2" />
<path d="M 18 53 L 24 28 Q 34 20 55 20 L 62 20 L 58 56 L 28 56 Q 20 56 18 53 Z" fill="#eab308" />
<path d="M 27 31 L 34 24 L 48 24 L 43 39 L 27 39 Z" fill="#0f172a" />
<rect x="52" y="21" width="98" height="3" fill="#1d4ed8" />
<rect x="28" y="54" width="122" height="2" fill="#1d4ed8" />
<rect x="66" y="28" width="18" height="13" rx="2" fill="#1e293b" />
<rect x="88" y="28" width="18" height="13" rx="2" fill="#1e293b" />
<rect x="110" y="24" width="17" height="32" rx="1.5" fill="#e2e8f0" stroke="#64748b" strokeWidth="0.8" />
<line x1="118.5" y1="24" x2="118.5" y2="56" stroke="#64748b" strokeWidth="0.8" />
<rect x="131" y="28" width="15" height="13" rx="2" fill="#1e293b" />
<circle cx="44" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
<circle cx="58" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
<circle cx="126" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
<circle cx="140" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
<rect x="150" y="28" width="5" height="25" fill="#1e293b" />
<rect x="155" y="20" width="150" height="36" rx="1" fill="#f1f5f9" stroke="#475569" strokeWidth="1.2" />
<rect x="155" y="21" width="150" height="3" fill="#1d4ed8" />
<rect x="155" y="54" width="150" height="2" fill="#1d4ed8" />
<rect x="162" y="28" width="18" height="13" rx="2" fill="#1e293b" />
<rect x="184" y="28" width="18" height="13" rx="2" fill="#1e293b" />
<rect x="206" y="24" width="17" height="32" rx="1.5" fill="#e2e8f0" stroke="#64748b" strokeWidth="0.8" />
<line x1="214.5" y1="24" x2="214.5" y2="56" stroke="#64748b" strokeWidth="0.8" />
<rect x="227" y="28" width="18" height="13" rx="2" fill="#1e293b" />
<rect x="249" y="28" width="18" height="13" rx="2" fill="#1e293b" />
<rect x="271" y="28" width="18" height="13" rx="2" fill="#1e293b" />
<circle cx="178" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
<circle cx="192" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
<circle cx="270" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
<circle cx="284" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
<rect x="305" y="28" width="5" height="25" fill="#1e293b" />
<path d="M 310 20 L 405 20 Q 426 20 436 28 L 442 53 Q 440 56 432 56 L 310 56 Z" fill="#f1f5f9" stroke="#475569" strokeWidth="1.2" />
<path d="M 398 20 L 405 20 Q 426 20 436 28 L 442 53 Q 440 56 432 56 L 402 56 Z" fill="#eab308" />
<path d="M 433 31 L 426 24 L 412 24 L 417 39 L 433 39 Z" fill="#0f172a" />
<rect x="310" y="21" width="95" height="3" fill="#1d4ed8" />
<rect x="310" y="54" width="122" height="2" fill="#1d4ed8" />
<rect x="316" y="28" width="15" height="13" rx="2" fill="#1e293b" />
<rect x="335" y="24" width="17" height="32" rx="1.5" fill="#e2e8f0" stroke="#64748b" strokeWidth="0.8" />
<line x1="343.5" y1="24" x2="343.5" y2="56" stroke="#64748b" strokeWidth="0.8" />
<rect x="356" y="28" width="18" height="13" rx="2" fill="#1e293b" />
<rect x="378" y="28" width="18" height="13" rx="2" fill="#1e293b" />
<circle cx="320" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
<circle cx="334" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
<circle cx="402" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
<circle cx="416" cy="61" r="5" fill="#334155" stroke="#0f172a" strokeWidth="1.5" />
</svg>
</div>
)}
</div>
<span className="text-[#0070e0] font-bold text-[18px] select-none">›</span>
</div>
</div>
{/* ─── SERVICES À BORD ─── */}
<div className="pt-3 px-1">
<div className="text-[12px] font-medium text-gray-700 mb-2.5">
Services à bord de ce train
</div>
<div className="flex items-center gap-4 text-gray-700">
<svg viewBox="0 0 24 24" className="w-[18px] h-[18px] fill-current" xmlns="http://www.w3.org/2000/svg">
<path d="M11 2v4.18l-2.09-1.2-1 1.73L10 8l-2.09 1.29 1 1.73L11 9.82V13h2V9.82l2.09 1.2 1-1.73L14 8l2.09-1.29-1-1.73L13 6.18V2h-2zm-9 9v2h4.18l-1.2 2.09 1.73 1L8 14l1.29 2.09 1.73-1L9.82 13H13v-2H9.82l1.2-2.09-1.73-1L8 10l-1.29-2.09-1.73 1L6.18 11H2zm11 2v4.18l2.09 1.2 1-1.73L14 16l2.09-1.29-1-1.73L13 14.18V13h-2v3.18l-2.09-1.2-1 1.73L10 16l-2.09 1.29 1 1.73L11 17.82V22h2v-4.18l2.09-1.2 1 1.73L14 16l2.09-1.29-1-1.73L13 14.18V13h2zm5-2v2h4.18l-1.2 2.09 1.73 1L20 14l1.29 2.09 1.73-1L21.82 13H18v-2h3.82l-1.2-2.09 1.73-1L20 10l-1.29-2.09-1.73 1L18.18 11H18z" />
</svg>
<svg viewBox="0 0 24 24" className="w-[18px] h-[18px] fill-current" xmlns="http://www.w3.org/2000/svg">
<path d="M16 7V3h-2v4h-4V3H8v4C6.9 7 6 7.9 6 9v3c0 2.21 1.79 4 4 4v5h4v-5c2.21 0 4-1.79 4-4V9c0-1.1-.9-2-2-2z" />
</svg>
<svg viewBox="0 0 24 24" className="w-[19px] h-[19px] fill-current" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zM5 12c-2.8 0-5 2.2-5 5s2.2 5 5 5 5-2.2 5-5-2.2-5-5-5zm0 8.5c-1.9 0-3.5-1.6-3.5-3.5s1.6-3.5 3.5-3.5 3.5 1.6 3.5 3.5-1.6 3.5-3.5 3.5zm5.8-10l2.4-2.4.8.8c1.3 1.3 3 2.1 5 2.1V9c-1.5 0-2.9-.6-3.9-1.6l-1.7-1.7c-.4-.4-.9-.7-1.5-.7s-1.1.2-1.5.6L7.8 8.4c-.5.5-.8 1.1-.8 1.8 0 .7.3 1.4.8 1.9l3.4 3.4V20h2v-5.5l-2.4-2.4zM19 12c-2.8 0-5 2.2-5 5s2.2 5 5 5 5-2.2 5-5-2.2-5-5-5zm0 8.5c-1.9 0-3.5-1.6-3.5-3.5s1.6-3.5 3.5-3.5 3.5 1.6 3.5 3.5-1.6 3.5-3.5 3.5z" />
</svg>
</div>
<div className="text-[11px] text-gray-500 mt-2.5">
{materielInfo && (() => {
const parts = currentDateDisplay.split('/');
const searchDate = parts.length === 3 ? new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0])) : new Date();
const diffDays = Math.ceil((searchDate.getTime() - new Date().setHours(0,0,0,0)) / (1000 * 60 * 60 * 24));
return diffDays <= 1;
})()
? `1 train "${materielInfo.nom}" ${materielInfo.nom_technique ? `(${materielInfo.nom_technique})` : ''} - ${materielInfo.voitures} voitures - ${materielInfo.places} places`
: '1 train « AGC » - 3 voitures - 163 places'}
</div>
</div>
{/* ─── CARD: PERTURBATION (Retard ou Suppression) ─── */}
{perturbationInfo && (
<div className={`bg-white rounded-2xl p-4 shadow-sm border flex items-center justify-between gap-3 mt-4 ${isSuppressed ? 'border-red-200' : 'border-gray-100'}`}>
<div className="flex items-center gap-3">
<div className={`w-6 h-6 rounded-full text-white flex items-center justify-center shrink-0 ${isSuppressed ? 'bg-red-600' : 'bg-[#9a3412]'}`}>
{isSuppressed ? (
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
) : (
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm4.2 14.2L11 13V7h1.5v5.2l4.5 2.7-.8 1.3z" />
</svg>
)}
</div>
<div>
<div className={`font-bold text-[13px] leading-tight ${isSuppressed ? 'text-red-700' : 'text-[#9a3412]'}`}>
{isSuppressed ? 'Train supprimé' : `Retard estimé à ${delayMinutes} min`}
</div>
<div className="text-[11px] text-gray-600 mt-0.5">
Train {trainType} {trainNumber}
</div>
</div>
</div>
<span className="text-[#0070e0] font-bold text-[18px] select-none">›</span>
</div>
)}
{/* ─── DESTINATION & OPÉRATEUR ─── */}
<div className="pt-5 px-1">
<h2 className="text-[14px] font-bold text-gray-900 leading-tight">
Destination {destination}
</h2>
<p className="text-[11.5px] text-gray-500 mt-1">
Opéré par {operatorInfo}
</p>
</div>
{/* ═══════════════════════════════════════════════════════ */}
{/* TIMELINE VERTICALE */}
{/* ═══════════════════════════════════════════════════════ */}
<div className="relative pt-6 pb-8">
<div
className="absolute top-8 bottom-10 w-[5px] rounded-full"
style={{ backgroundColor: isSuppressed ? '#e5e7eb' : theme.primary, left: '82px' }}
/>
<div className="flex flex-col" style={{ gap: '36px' }}>
{stops.map((stop, idx) => {
const isDelayed = !isSuppressed && delayMinutes > 0 && stop.heure_estimee !== stop.heure_prevue;
const isFirst = idx === 0;
const isLast = idx === stops.length - 1;
return (
<div key={idx} className={`relative flex items-start ${isSuppressed ? 'opacity-60' : ''}`}>
<div className="w-[72px] shrink-0 text-right pr-3 pt-px">
<span
className="inline-block px-2.5 py-[5px] rounded-full font-bold text-[12.5px] text-white leading-none tracking-tight"
style={{ backgroundColor: isSuppressed ? '#9ca3af' : theme.primary }}
>
{isSuppressed ? stop.heure_prevue : (stop.heure_estimee || stop.heure_prevue)}
</span>
{isDelayed && (
<span className="block text-[10px] text-gray-400 line-through mt-1 pr-1 font-normal">
{stop.heure_prevue}
</span>
)}
</div>
<div className="w-[24px] shrink-0 flex justify-center pt-[3px] relative z-10">
<div
className="w-[13px] h-[13px] rounded-full bg-white border-[3px] shadow-sm"
style={{ borderColor: isSuppressed ? '#9ca3af' : theme.primary }}
/>
</div>
<div className="flex-1 pl-2 pt-0">
<div className={`font-bold text-[15px] leading-tight ${isSuppressed ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
{stop.gare_nom}
</div>
{stop.duree_arret && !isFirst && !isLast && (
<div className="text-[11.5px] text-gray-400 mt-1 font-normal italic">
{stop.duree_arret}
</div>
)}
{stop.voie && !isSuppressed && (
<div
className="text-[11.5px] font-semibold mt-0.5 inline-block px-2 py-0.5 rounded bg-gray-100"
style={{ color: theme.primary }}
>
{stop.voie}
</div>
)}
{isFirst && (
<div className="text-[11px] text-gray-400 mt-1 font-normal">
Départ
</div>
)}
{isLast && (
<div className="text-[11px] text-gray-400 mt-1 font-normal">
Arrivée
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
{/* RIGHT COLUMN (Perturbation Message Panel) */}
{perturbationInfo?.message && (
<div className="w-full lg:w-[320px] shrink-0">
<div className="sticky top-6">
<div className={`rounded-xl p-5 shadow-sm border ${isSuppressed ? 'bg-red-50 border-red-200' : 'bg-orange-50 border-orange-200'}`}>
<div className="flex items-center gap-3 mb-3">
<div className={`w-8 h-8 rounded-full text-white flex items-center justify-center shrink-0 ${isSuppressed ? 'bg-red-600' : 'bg-orange-600'}`}>
<svg viewBox="0 0 24 24" className="w-4 h-4 fill-current" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
</div>
<h3 className={`font-bold text-[15px] ${isSuppressed ? 'text-red-900' : 'text-orange-900'}`}>
Info Trafic
</h3>
</div>
<p className={`text-[13px] leading-relaxed whitespace-pre-wrap ${isSuppressed ? 'text-red-800' : 'text-orange-800'}`}>
{perturbationInfo.message}
</p>
</div>
</div>
</div>
)}
</div>
</main>
</div>
{/* SITE FOOTER */}
<SiteFooter regionSlug={regionSlug} />
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
export function isTrainActiveOnDate(horaire: any, dateIso: string): boolean {
if (!horaire) return false;
const d = new Date(dateIso);
// days in JS: 0 = Sun, 1 = Mon, ..., 6 = Sat
const dayOfWeek = d.getDay();
// Helper to extract date string from element
const hasDate = (arr: any[], dateTarget: string) => {
return arr.some((item) => {
if (typeof item === 'string') return item.startsWith(dateTarget);
if (typeof item === 'object' && item.date) return item.date.startsWith(dateTarget);
return false;
});
};
// Custom non-circulation days overrides everything
if (horaire.jours_non_circulation && Array.isArray(horaire.jours_non_circulation)) {
if (hasDate(horaire.jours_non_circulation, dateIso)) {
return false;
}
}
// Custom circulation days overrides weekly schedule
if (horaire.jours_personnalises && Array.isArray(horaire.jours_personnalises)) {
// some custom days just list all the days the train runs and the weekly flags might be false
// wait, if it's IN jours_personnalises, it circulates.
if (hasDate(horaire.jours_personnalises, dateIso)) {
return true;
}
}
// Check weekly schedule
const weekDays = [
horaire.circule_dimanche,
horaire.circule_lundi,
horaire.circule_mardi,
horaire.circule_mercredi,
horaire.circule_jeudi,
horaire.circule_vendredi,
horaire.circule_samedi
];
return weekDays[dayOfWeek] === true;
}
+14
View File
@@ -0,0 +1,14 @@
function isWithinJPlus1(dateDisplay) {
const parts = dateDisplay.split('/');
if (parts.length === 3) {
const searchDate = new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0]));
const today = new Date();
today.setHours(0, 0, 0, 0);
const diffTime = searchDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays <= 1;
}
return true;
}
console.log(isWithinJPlus1("25/09/2026")); // Tomorrow
console.log(isWithinJPlus1("26/09/2026")); // J+2 -> false
+16
View File
@@ -0,0 +1,16 @@
const q = "891811 de lundi";
const days = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const today = new Date();
let fallbackDate = null;
const foundDay = days.find(d => q.includes(d));
if (foundDay) {
const currentDay = today.getDay();
const targetDay = days.indexOf(foundDay);
let diff = targetDay - currentDay;
if (diff < 0) diff += 7; // next occurrence of that day
if (diff === 0 && q.includes('prochain')) diff += 7;
const targetDate = new Date(today);
targetDate.setDate(today.getDate() + diff);
fallbackDate = targetDate.toISOString().split('T')[0];
}
console.log(fallbackDate);
+20
View File
@@ -0,0 +1,20 @@
const fs = require('fs');
const env = fs.readFileSync('.env.local', 'utf8').split('\n').reduce((acc, line) => {
const [k, ...v] = line.split('=');
if (k && v) acc[k] = v.join('=');
return acc;
}, {});
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('horaires').select('*').eq('numero_train', '891811');
if (data && data.length > 0) {
console.log("Train:", data[0].numero_train);
console.log("circule_jeudi:", data[0].circule_jeudi);
console.log("jours_personnalises:", data[0].jours_personnalises);
console.log("jours_non_circulation:", data[0].jours_non_circulation);
} else {
console.log("No data", error);
}
}
run();
+13
View File
@@ -0,0 +1,13 @@
const fs = require('fs');
const env = fs.readFileSync('.env.local', 'utf8').split('\n').reduce((acc, line) => {
const [k, ...v] = line.split('=');
if (k && v) acc[k] = v.join('=');
return acc;
}, {});
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('horaires').select('id, numero_train, actif, circule_jeudi, jours_personnalises').eq('numero_train', '891811');
console.log(data);
}
run();
+16
View File
@@ -0,0 +1,16 @@
const fs = require('fs');
const env = fs.readFileSync('.env.local', 'utf8').split('\n').reduce((acc, line) => {
const [k, ...v] = line.split('=');
if (k && v) acc[k] = v.join('=');
return acc;
}, {});
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
async function run() {
const { data } = await supabase.from('horaires').select('id, jours_personnalises').eq('numero_train', '891811');
data.forEach(d => {
const has24 = d.jours_personnalises.some(jp => jp.date === '2026-09-24' || jp.date === '2026-09-24T00:00:00.000Z' || String(jp).includes('2026-09-24'));
console.log("ID:", d.id, "Has 2026-09-24?", has24);
});
}
run();