diff --git a/.env.production.example b/.env.production.example index 17d8012..45beb24 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,6 +1,7 @@ # Configuration Supabase (indispensable au moment du build Next.js) -NEXT_PUBLIC_SUPABASE_URL=https://votre-projet.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=votre-cle-anon-supabase +NEXT_PUBLIC_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr +NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_n0jG59Ggpz2IYa5PW3LlJl_X_0W68og + # Configuration Serveur PORT=3000 diff --git a/check_clients.js b/check_clients.js new file mode 100644 index 0000000..47bbb71 --- /dev/null +++ b/check_clients.js @@ -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(); diff --git a/check_db.js b/check_db.js new file mode 100644 index 0000000..6313734 --- /dev/null +++ b/check_db.js @@ -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(); diff --git a/check_utilisateurs.js b/check_utilisateurs.js new file mode 100644 index 0000000..828616c --- /dev/null +++ b/check_utilisateurs.js @@ -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(); diff --git a/fix_details.js b/fix_details.js new file mode 100644 index 0000000..5b3f7b3 --- /dev/null +++ b/fix_details.js @@ -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); diff --git a/fix_pages.js b/fix_pages.js new file mode 100644 index 0000000..a52ed57 --- /dev/null +++ b/fix_pages.js @@ -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(//, ''); + + fs.writeFileSync(p, content); + console.log(`Updated ${p}`); + } else { + console.log(`Already has searchParams ${p}`); + } + } else { + console.log(`Not found: ${p}`); + } +}); diff --git a/fix_search.js b/fix_search.js new file mode 100644 index 0000000..d3b13cd --- /dev/null +++ b/fix_search.js @@ -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); diff --git a/nginx/ter-ferrovia.mrpatator.fr.conf b/nginx/ter-ferrovia.mrpatator.fr.conf index d556915..aadf6a2 100644 --- a/nginx/ter-ferrovia.mrpatator.fr.conf +++ b/nginx/ter-ferrovia.mrpatator.fr.conf @@ -1,52 +1,43 @@ # ============================================================================== # Configuration Nginx pour ter-ferrovia.mrpatator.fr -# Emplacement recommandé 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/ +# Emplacement sur le serveur : /etc/nginx/sites-available/ter-ferrovia.mrpatator.fr # ============================================================================== -# Bloc HTTP : Redirection automatique vers HTTPS (une fois le certificat généré) +# Redirection HTTP -> HTTPS server { listen 80; listen [::]:80; server_name ter-ferrovia.mrpatator.fr; - # Emplacement pour le challenge Let's Encrypt (Certbot) location /.well-known/acme-challenge/ { root /var/www/certbot; } - # Redirection vers HTTPS location / { return 301 https://$host$request_uri; } } -# Bloc HTTPS : Reverse Proxy vers l'application Next.js (PM2 port 3000) +# Bloc HTTPS server { - listen 443 ssl http2; - listen [::]:443 ssl http2; + listen 443 ssl; + listen [::]:443 ssl; + http2 on; 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_key /etc/letsencrypt/live/ter-ferrovia.mrpatator.fr/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; 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é add_header X-Frame-Options "SAMEORIGIN" always; add_header X-XSS-Protection "1; mode=block" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "no-referrer-when-downgrade" always; - # Compression Gzip pour de meilleures performances + # Compression Gzip gzip on; gzip_proxied any; gzip_comp_level 6; @@ -61,16 +52,14 @@ server { add_header Cache-Control "public, max-age=31536000, immutable"; } - # Reverse Proxy principal vers Next.js + # Reverse Proxy vers Next.js (PM2 port 3000) location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; - # En-têtes pour WebSockets et Server-Sent Events proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; - # En-têtes de transmission du client d'origine proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; 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-Port $server_port; - # Timeouts de proxy proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; diff --git a/package-lock.json b/package-lock.json index 316929c..e887440 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "ferrovia-regional", "version": "0.1.0", "dependencies": { + "@google/generative-ai": "^0.24.1", "@supabase/supabase-js": "^2.112.4", "lucide-react": "^1.34.0", "next": "16.3.3", @@ -457,6 +458,15 @@ "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": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", diff --git a/package.json b/package.json index ea788c2..5fd7179 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@google/generative-ai": "^0.24.1", "@supabase/supabase-js": "^2.112.4", "lucide-react": "^1.34.0", "next": "16.3.3", diff --git a/query.js b/query.js new file mode 100644 index 0000000..28d66c4 --- /dev/null +++ b/query.js @@ -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(); diff --git a/schema.js b/schema.js new file mode 100644 index 0000000..4ac5f97 --- /dev/null +++ b/schema.js @@ -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(); diff --git a/src/app/[region]/compte/page.tsx b/src/app/[region]/compte/page.tsx new file mode 100644 index 0000000..cc2fb8f --- /dev/null +++ b/src/app/[region]/compte/page.tsx @@ -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 ; +} diff --git a/src/app/[region]/horaires/train/[trainNumber]/page.tsx b/src/app/[region]/horaires/train/[trainNumber]/page.tsx new file mode 100644 index 0000000..015215a --- /dev/null +++ b/src/app/[region]/horaires/train/[trainNumber]/page.tsx @@ -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 ( + +
+
+

Chargement des détails du train {trainNumber}...

+
+ + }> + +
+ ); +} diff --git a/src/app/[region]/se-deplacer/horaires/train/[trainNumber]/page.tsx b/src/app/[region]/se-deplacer/horaires/train/[trainNumber]/page.tsx new file mode 100644 index 0000000..015215a --- /dev/null +++ b/src/app/[region]/se-deplacer/horaires/train/[trainNumber]/page.tsx @@ -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 ( + +
+
+

Chargement des détails du train {trainNumber}...

+
+ + }> + +
+ ); +} diff --git a/src/app/api/search/analyze/route.ts b/src/app/api/search/analyze/route.ts new file mode 100644 index 0000000..a25d6d5 --- /dev/null +++ b/src/app/api/search/analyze/route.ts @@ -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 }); + } +} diff --git a/src/app/compte/page.tsx b/src/app/compte/page.tsx new file mode 100644 index 0000000..a4a3299 --- /dev/null +++ b/src/app/compte/page.tsx @@ -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 ; +} diff --git a/src/app/horaires/train/[trainNumber]/page.tsx b/src/app/horaires/train/[trainNumber]/page.tsx new file mode 100644 index 0000000..2f44826 --- /dev/null +++ b/src/app/horaires/train/[trainNumber]/page.tsx @@ -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 ( + +
+
+

Chargement des détails du train {trainNumber}...

+
+ + }> + +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 2991194..c7f5008 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,24 +1,81 @@ -import { redirect } from 'next/navigation'; -import { supabase } from '@/lib/supabase'; +import React from 'react'; +import Link from 'next/link'; -export const dynamic = 'force-dynamic'; -export const revalidate = 0; +export const metadata = { + 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 { data } = await supabase - .from('parametres') - .select('valeur') - .eq('cle', 'general') - .single(); +const regions = [ + { slug: 'bourgogne-franche-comte', name: 'Bourgogne-Franche-Comté', logo: 'https://www.ter.sncf.com/assets/medias/bfc/headerLogo.svg', color: '#006e39' }, + { slug: 'occitanie', name: 'Occitanie', logo: 'https://www.ter.sncf.com/assets/medias/occitanie/headerLogo.svg', color: '#c0003b' }, + { 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' }, + { slug: 'bretagne', name: 'Bretagne', logo: 'https://www.ter.sncf.com/assets/medias/bretagne/headerLogo.svg', color: '#00a3a6' }, + { 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'; - if (data && data.valeur) { - const val = data.valeur as any; - if (val.region) { - targetRegion = val.region; - } - } +export default function PortalPage() { + return ( +
+
+

+ SNCF Voyageurs +

+

+ Portail d'accès aux sites régionaux TER +

+
- redirect(`/${targetRegion}`); +
+
+

Choisissez votre région

+

Cliquez sur une région pour accéder à ses horaires, itinéraires et actualités.

+
+ +
+ {regions.map((region) => ( + +
+ {`Logo +
+

+ {region.name} +

+
+ + ))} +
+ +
+
+

+ info + Application conçue pour les déploiements dynamiques Vercel via les slugs /[region] +

+
+
+
+ +
+

© 2026 - SNCF Voyageurs / TER Ferrovia - Prototype Vercel

+
+
+ ); } - diff --git a/src/app/se-deplacer/horaires/train/[trainNumber]/page.tsx b/src/app/se-deplacer/horaires/train/[trainNumber]/page.tsx new file mode 100644 index 0000000..2f44826 --- /dev/null +++ b/src/app/se-deplacer/horaires/train/[trainNumber]/page.tsx @@ -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 ( + +
+
+

Chargement des détails du train {trainNumber}...

+
+ + }> + +
+ ); +} diff --git a/src/components/CompteClient.tsx b/src/components/CompteClient.tsx new file mode 100644 index 0000000..bef4a23 --- /dev/null +++ b/src/components/CompteClient.tsx @@ -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(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(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 ( +
+ {step !== 1 && } + +
+ + {/* ======================= */} + {/* ETAPE 0 : PAGE ACCUEIL COMPTE (IMG 1) */} + {/* ======================= */} + {step === 0 && ( +
+
+ home + > + Connexion +
+ +
+

+ Gagnez du temps avec votre compte client ! +

+

+ Toutes vos informations seront pré-remplies +

+ +
+
+ confirmation_number +

Retrouvez toutes vos commandes TER au même endroit

+
+
+ storefront +

Enregistrez vos gares et voyageurs favoris

+
+
+ credit_card +

Ajoutez votre carte bancaire et réglez en 1 clic

+
+
+ chat +

Consultez l'évolution du traitement de vos réclamations

+
+
+ +
+ + +
+
+
+ )} + + {/* ======================= */} + {/* ETAPE 1 : LOGIN SNCF (IMG 2) */} + {/* ======================= */} + {step === 1 && ( +
+
+ + {/* Left red side */} +
+
+ Mon Identifiant SNCF +
+

+ Une solution d'identification par
SNCF Voyageurs +

+ + {/* Minimalist drawing imitation */} +
+
+ {/* Character drawing (simplified SVG representation) */} +
+ + + + + + + + + + + + + + + +
+
+
+ + {/* Right white side */} +
+
+
SNCF
VOYAGEURS
+ Mon Identifiant SNCF +
+ +
+

Bienvenue

+

+ Connectez-vous pour accéder aux espaces clients des sites et applications SNCF et ses partenaires(1). +

+
+ + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {successMsg && ( +
+ {successMsg} +
+ )} + +
+ + {isSignUp && ( +
+ 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 + /> + 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 + /> +
+ )} + + 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 + /> + + 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} + /> + +
+ + {!isSignUp && ( + + )} +
+ +
+

En cliquant sur "{isSignUp ? "Créer mon compte" : "Se connecter"}", j'accepte :

+

+ Les conditions générales d'utilisation de Mon Identifiant SNCF open_in_new +

+

+ La politique de gestion des Données à Caractère Personnel de Mon Identifiant SNCF open_in_new +

+
+ +
+ + +
+ +
+ +
+
+
+
+ +
+ Accessibilité : partiellement conforme open_in_new + Langue : 🇫🇷 Français arrow_drop_down +
+
+ )} + + {/* ======================= */} + {/* ETAPE 2 : DASHBOARD (IMG 3) */} + {/* ======================= */} + {step === 2 && ( +
+ + {/* Sidebar */} +
+
+ Bienvenue {userData?.prenom || 'Client'} +
+
    +
  • + Mes informations personnelles +
  • +
  • + Mes billets +
  • +
  • + Mes abonnements et cartes de réduction +
  • +
  • + Mes voyageurs favoris +
  • +
  • + Mes moyens de paiement +
  • +
  • + Mes gares favorites +
  • +
  • + Mes demandes +
  • +
  • + Mes préférences +
  • +
  • + logout + Me déconnecter +
  • +
+
+ + {/* Main Content */} +
+
+

Mes informations personnelles

+ {successMsg && ( + + {successMsg} + + )} + {errorMsg && ( + + {errorMsg} + + )} +
+ +
+ {/* Coordonnées */} +
+
+

Mes coordonnées personnelles

+ +
+ +
+
+

Nom

+

{userData?.nom || '-'}

+
+
+

Prénom

+

{userData?.prenom || '-'}

+
+
+

Date de naissance

+

{userData?.date_naissance || '-'}

+
+
+ +
+

Téléphone mobile (optionnel)

+

{userData?.telephone || '-'}

+
+
+ + {/* Identifiants */} +
+
+

Mes identifiants

+
+ +
+
+
+

E-mail

+

{user?.email}

+
+ +
+
+
+

Mot de passe

+

••••••••••

+
+ +
+ + {/* OPTION CREATION CLE D'ACCES (PASSKEY) */} +
+
+

+ fingerprint + Clé d'accès (Passkey) +

+

Connectez-vous avec votre empreinte digitale ou Face ID au lieu d'un mot de passe.

+
+ +
+
+
+ + {/* Carte de réduction */} +
+
+

Ma carte de réduction

+
+ +
+

Sélectionner une carte de réduction (optionnel)

+ +
+
+ + {/* RGPD Disclaimer */} +
+ * 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é. +
+
+ +
+
+ )} + +
+ + {step !== 1 && } +
+ ); +} diff --git a/src/components/OffersPageClient.tsx b/src/components/OffersPageClient.tsx index da59aab..8f1e945 100644 --- a/src/components/OffersPageClient.tsx +++ b/src/components/OffersPageClient.tsx @@ -445,7 +445,11 @@ export default function OffersPageClient({ // Extract matériel roulant image from composition_train BDD const composition = Array.isArray(item.composition_train) ? item.composition_train : []; const firstMat = composition.length > 0 ? composition[0] : null; - const matImageUrl = firstMat?.materiel_image_url || 'https://supabase-ferrovia.mrpatator.fr/storage/v1/object/public/materiel-roulant/images/1789909846752_regiolis_firsttosecond_bluegreenmobigo_4c__1_.png'; + + const 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"'); mappedList.push({ diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index dd7c41b..31b3f03 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -2,9 +2,12 @@ import React, { useState } from 'react'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import HeaderMenu, { MenuItem } from '@/components/HeaderMenu'; import { getRegionTheme } from '@/lib/regionTheme'; +import SmartSearchBar from '@/components/SmartSearchBar'; + interface SiteHeaderProps { regionSlug?: string; logoUrl?: string; @@ -39,7 +42,9 @@ export default function SiteHeader({ activeTab, onTabSelect, }: SiteHeaderProps) { + const router = useRouter(); const theme = getRegionTheme(regionSlug); + const [showSmartSearch, setShowSmartSearch] = useState(false); const [showOrderModal, setShowOrderModal] = useState(false); const [showCartDrawer, setShowCartDrawer] = useState(false); const [showAccountModal, setShowAccountModal] = useState(false); @@ -100,14 +105,29 @@ export default function SiteHeader({ - {/* Right: Actions (Commande, Panier, Compte - Img 2) */} + {/* Right: Actions (Rechercher, Commande, Panier, Compte - Img 2) */}
+ {/* Rechercher */} + + +
+ {/* Commande */}
)} + {/* SMART SEARCH BAR DRAWER (Slide in right to left) */} + setShowSmartSearch(false)} + regionSlug={regionSlug} + /> + ); } diff --git a/src/components/SmartSearchBar.tsx b/src/components/SmartSearchBar.tsx new file mode 100644 index 0000000..dae5512 --- /dev/null +++ b/src/components/SmartSearchBar.tsx @@ -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(null); + + const [query, setQuery] = useState(''); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(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 ( +
+
+ +
+
+
+
+ + + auto_awesome + + Recherche IA + + +
+ +
+
+ + search + + 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')} + /> + +
+
+
+ +
+ {loading && ( +
+
+

L'IA analyse votre demande...

+
+ )} + + {!loading && result && ( +
+
+
+ + {result.type === 'error' ? 'error_outline' : + result.type === 'redirect' ? 'check_circle' : 'info'} + +

+ {result.message} +

+
+
+ + {result.type === 'options' && result.options && ( +
+ {result.options.map((opt: any, idx: number) => ( + + ))} +
+ )} +
+ )} + + {!loading && !result && ( +
+

Exemples de requêtes

+
+ {getQuickLinks().map((link, idx) => ( + + ))} +
+ +
+ tips_and_updates +
+

Recherche Intelligente par IA

+

+ Tapez naturellement ce que vous cherchez. Exemples :
+ • "Est-ce que le train 891811 circule demain ?"
+ • "Quels sont les fiches horaires de la gare de Nevers ?"
+ • "Itinéraires de Dijon à Seurre" +

+
+
+
+ )} +
+
+
+
+ ); +} diff --git a/src/components/TimetableSearchForm.tsx b/src/components/TimetableSearchForm.tsx index 751b960..01c39c5 100644 --- a/src/components/TimetableSearchForm.tsx +++ b/src/components/TimetableSearchForm.tsx @@ -755,8 +755,10 @@ export default function TimetableSearchForm({ const isSelected = dayNum === selectedDayNumber; const today = new Date(); 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 isPastDate = dayDate < today; + const isPastDate = dayDate < sevenDaysAgo; return (