/* Voyage Assistant — pages for the interactive prototype. Home, Results, Booking, Confirmation, MesBillets. Uses globals from voyage-icons.jsx + voyage-shared.jsx. */ /* ── Fake-QR generator (deterministic pseudo-random grid) ── */ const QRCode = ({ seed = 'TRV-2026-AX42K9', size = 120 }) => { const N = 21; // simple hash → 0/1 grid; finder patterns in 3 corners const cells = React.useMemo(() => { let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0; const rng = () => { h = (h * 1664525 + 1013904223) >>> 0; return h / 4294967295; }; const g = Array.from({ length: N }, () => Array.from({ length: N }, () => rng() > 0.52 ? 1 : 0)); // finder patterns const place = (r, c) => { for (let i = 0; i < 7; i++) for (let j = 0; j < 7; j++) g[r+i][c+j] = 0; for (let i = 0; i < 7; i++) { g[r][c+i] = 1; g[r+6][c+i] = 1; g[r+i][c] = 1; g[r+i][c+6] = 1; } for (let i = 2; i < 5; i++) for (let j = 2; j < 5; j++) g[r+i][c+j] = 1; }; place(0, 0); place(0, N - 7); place(N - 7, 0); // timing lines for (let i = 8; i < N - 8; i++) { g[6][i] = i % 2 === 0 ? 1 : 0; g[i][6] = i % 2 === 0 ? 1 : 0; } return g; }, [seed]); const cell = size / N; return ( {cells.map((row, r) => row.map((v, c) => v ? : null))} ); }; /* ────────────────────────────────────────────────────────── HOME — based on variant B (minimaliste) with subtle photo ──────────────────────────────────────────────────────── */ const SearchEditor = ({ values, onClose, onApply, onSearch }) => { const [v, setV] = React.useState(values); const apply = () => { onApply(v); onClose(); }; const search = () => { onApply(v); onSearch(v); onClose(); }; return (
{ if (e.target.classList.contains('va-modal-overlay')) onClose(); }}>

{t('Modifier la recherche')}

{t('Définissez votre trajet, on cherche les billets disponibles.')}

setV({ ...v, tripType: t })} />
{t('Départ')} setV({ ...v, depart: val })} onPick={(it) => setV({ ...v, depart: it.nom })} />
{t('Arrivée')} setV({ ...v, arrivee: val })} onPick={(it) => setV({ ...v, arrivee: it.nom })} />
{isRoundTrip(v.tripType) ? ( ) : (
{t('Voyageurs')} setV({ ...v, pax, passagers: paxCount(pax) })} />
)}
{isRoundTrip(v.tripType) && (
{t('Voyageurs')} setV({ ...v, pax, passagers: paxCount(pax) })} />
)}
); }; const HomePage = ({ go }) => { const [mode, setMode] = React.useState('all'); const [search, setSearch] = React.useState({ depart: 'Paris', arrivee: 'Marseille', date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), dateRetour: new Date(Date.now() + 14 * 86400000).toISOString().slice(0, 10), tripType: 'aller', pax: { adultes: 1, enfants: 0, bebes: 0 }, }); const [editorOpen, setEditorOpen] = React.useState(false); const [dbStats, setDbStats] = React.useState(null); React.useEffect(() => { fetch(`${window.VA_CONFIG.API_BASE}/stats`) .then(r => r.json()) .then(d => setDbStats(d)) .catch(() => {}); }, []); return (
{t('Voyage Assistant — réservation multi-mode')}

{t('Où voulez-vous')}
{t('aller cette saison ?')}

{t('Avion, train, bateau, bus longue distance. Comparez')}{' '} {dbStats ? dbStats.compagnies.toLocaleString(window.VA_I18N.locale()) : '…'}{' '} {t('compagnies en un seul écran, gérez ensuite vos billets avec un assistant qui ne dort jamais.')}

go('results', { search: { ...v, type: MODE_KEY_TO_API[mode] } })} />
{dbStats ? dbStats.destinations.toLocaleString(window.VA_I18N.locale()) : '…'}{t('destinations')}
{dbStats ? dbStats.compagnies.toLocaleString(window.VA_I18N.locale()) : '…'}{t('compagnies')}
2,4 min{t('réservation moyenne')}
24 / 7{t('assistance')}
{t('Destinations qui appellent')}
{t('Sélection éditoriale, départ depuis Paris cette semaine.')}
go('results', { search: { ...search, type: MODE_KEY_TO_API[mode] } })}> {t('Voir tout')}
go('results', { search: { depart: 'Paris', arrivee: d.city, date: search.date, dateRetour: search.dateRetour, tripType: search.tripType, pax: search.pax, type: MODE_KEY_TO_API[mode] }, })} />
{t('Nouveau — Assistant IA')}
{t('Un retard, une modif, une réclamation ? Demandez à l’assistant.')}
{t('Il consulte votre billet après une rapide vérification d’identité, puis exécute la démarche à votre place — en moyenne en 1 min 40.')}
); }; /* ────────────────────────────────────────────────────────── RÉSULTATS ──────────────────────────────────────────────────────── */ const MODE_ICONS = { plane: IPlane, train: ITrain, ship: IShip, bus: IBus }; /* Points intermédiaires sur la ligne de trajet selon le nombre d'escales */ const StopDots = ({ stops }) => { const n = stops === '2 escales' ? 2 : stops === '1 escale' ? 1 : 0; if (n === 0) return null; const positions = n === 1 ? ['50%'] : ['33%', '67%']; return positions.map((left, i) => ( React.createElement('span', { key: i, style: { position: 'absolute', top: '50%', left, width: 7, height: 7, borderRadius: '50%', background: 'var(--va-accent)', border: '2px solid var(--va-surface)', transform: 'translate(-50%, -50%)', zIndex: 2, }, }) )); }; const RouteCard = ({ r, onSelect }) => { const Icon = MODE_ICONS[r.mode] || ITrain; return (
onSelect(r)}>
{r.depart}
{r.from}
{r.dur}
0 ? 'var(--va-accent)' : undefined }}> {r.stops}
{r.escales && r.escales.length > 0 && (
{t('via')} {r.escales.join(' · ')}
)}
{r.arrive}
{r.to}
{r.company}
{r.class}
{r.price} €
{t('par voyageur')}
{r.tags.map((tag, i) => ( {tag.good && } {t(tag.label, tag.vars)} ))}
); }; const Checkrow = ({ label, count, active, onClick }) => ( ); const EditSearchPopover = ({ values, onClose, onApply }) => { const [v, setV] = React.useState(values); return (

{t('Modifier la recherche')}

setV({ ...v, tripType: t })} />
{t('Départ')} setV({ ...v, depart: val })} onPick={(it) => setV({ ...v, depart: it.nom })} />
{t('Arrivée')} setV({ ...v, arrivee: val })} onPick={(it) => setV({ ...v, arrivee: it.nom })} />
{isRoundTrip(v.tripType) ? ( ) : (
{t('Voyageurs')} setV({ ...v, pax, passagers: paxCount(pax) })} />
)}
{isRoundTrip(v.tripType) && (
{t('Voyageurs')} setV({ ...v, pax, passagers: paxCount(pax) })} />
)}
); }; const ResultsPage = ({ go, ctx }) => { const search = (ctx && ctx.search) || { depart: 'Paris', arrivee: 'Marseille', date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), pax: { adultes: 1, enfants: 0, bebes: 0 }, type: null, }; const [trajets, setTrajets] = React.useState(null); const [loading, setLoading] = React.useState(true); const [editOpen, setEditOpen] = React.useState(false); const [currentSearch, setCurrentSearch] = React.useState(search); const [fallback, setFallback] = React.useState(false); // Récupère TOUS les modes pour la recherche départ/arrivée donnée // (le filtre par mode est appliqué côté client via les checkboxes sidebar) React.useEffect(() => { setLoading(true); setFallback(false); // 1) Recherche exacte sans filtre type window.VA_API.searchTrajets({ depart: currentSearch.depart || undefined, arrivee: currentSearch.arrivee || undefined, }) .then((data) => { if (!data || data.length === 0) { // 2) Fallback : on relâche la destination setFallback(true); return window.VA_API.searchTrajets({ depart: currentSearch.depart || undefined }); } return data; }) .then((data) => { if (!data || data.length === 0) { // 3) Fallback total return window.VA_API.searchTrajets({}); } return data; }) .then((data) => { setTrajets(data || []); setBudgetMax(null); }) .catch(() => setTrajets([])) .finally(() => setLoading(false)); }, [currentSearch.depart, currentSearch.arrivee]); // Convertit un trajet API en format RouteCard avec tags générés const enrich = (t) => { const modeKey = MODE_API_TO_KEY[t.type] || 'train'; const depart = new Date(t.date_depart); const arrive = new Date(t.date_arrivee); const hh = (d) => `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; const mins = Math.round((arrive - depart) / 60000); const dur = `${Math.floor(mins / 60)} h ${String(mins % 60).padStart(2,'0')}`; const fmtLong = (d) => isNaN(d) ? '' : d.toLocaleDateString(window.VA_I18N.locale(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); const fmtShort = (d) => isNaN(d) ? '' : d.toLocaleDateString(window.VA_I18N.locale(), { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); const tags = []; if (t.has_wifi) tags.push({ label: 'Wi-Fi', good: false }); if (t.has_prise) tags.push({ label: 'Prise', good: false }); if (t.type === 'train' || t.type === 'bus') tags.push({ label: 'Émission –93 %', good: true }); if (t.prix < 35) tags.push({ label: 'Petit prix', good: true }); if (t.retard_minutes > 0) tags.push({ label: 'Retard {min} min', vars: { min: t.retard_minutes }, good: false }); return { id: t.id, _api: t, company: t.compagnie, class: t.classe || 'Standard', depart: hh(depart), arrive: hh(arrive), dateLong: fmtLong(depart), dateShort: fmtShort(depart), dateArriveeShort: fmtShort(arrive), from: t.depart, to: t.arrivee, dur, stops: t.stops || 'direct', escales: t.escales || [], dureeEscaleMin: t.duree_escale_min || 0, price: Math.round(t.prix), mode: modeKey, tags, }; }; const [sort, setSort] = React.useState('best'); const [budgetMax, setBudgetMax] = React.useState(null); // null = no filter applied yet const initialModeKey = currentSearch.type ? MODE_API_TO_KEY[currentSearch.type] : null; const allModesOn = { train: true, plane: true, bus: true, ship: true, direct: true, oneStop: true, twoStops: true, morning: true, afternoon: true, evening: true, }; const [filters, setFilters] = React.useState(() => { if (initialModeKey) { return { train: initialModeKey === 'train', plane: initialModeKey === 'plane', bus: initialModeKey === 'bus', ship: initialModeKey === 'ship', direct: true, oneStop: true, twoStops: true, morning: true, afternoon: true, evening: true, }; } return allModesOn; }); const toggle = (k) => setFilters((f) => ({ ...f, [k]: !f[k] })); const resetFilters = () => setFilters(allModesOn); const [userTouchedFilters, setUserTouchedFilters] = React.useState(false); const toggleSmart = (k) => { setUserTouchedFilters(true); toggle(k); }; // Auto-élargissement : si après chargement des trajets le mode initial n'a aucun trajet, // on bascule sur "tous les modes" automatiquement pour ne pas afficher une page vide. React.useEffect(() => { if (loading || userTouchedFilters || !trajets || trajets.length === 0) return; const modeKeys = ['train', 'plane', 'bus', 'ship']; const visible = trajets.map(t => MODE_API_TO_KEY[t.type] || 'train').filter(m => filters[m]).length; if (visible === 0) setFilters(allModesOn); }, [loading, trajets, userTouchedFilters]); const timeSlot = (hhmm) => { const h = parseInt(hhmm.split(':')[0], 10); if (h >= 6 && h < 12) return 'morning'; if (h >= 12 && h < 18) return 'afternoon'; if (h >= 18) return 'evening'; return 'night'; }; const sorted = React.useMemo(() => { const src = (trajets || []).map(enrich).filter(r => { if (!filters[r.mode]) return false; const slot = timeSlot(r.depart); if (slot === 'morning' && !filters.morning) return false; if (slot === 'afternoon' && !filters.afternoon) return false; if (slot === 'evening' && !filters.evening) return false; if (r.stops === 'direct' && !filters.direct) return false; if (r.stops === '1 escale' && !filters.oneStop) return false; if (r.stops === '2 escales' && !filters.twoStops) return false; if (r.stops !== 'direct' && r.stops !== '1 escale' && r.stops !== '2 escales' && !filters.oneStop) return false; if (budgetMax !== null && r.price > budgetMax) return false; return true; }); if (sort === 'cheap') return [...src].sort((a, b) => a.price - b.price); if (sort === 'fast') return [...src].sort((a, b) => a.dur.localeCompare(b.dur)); if (sort === 'early') return [...src].sort((a, b) => a.depart.localeCompare(b.depart)); return src; }, [sort, filters, trajets, budgetMax]); const counts = React.useMemo(() => { const all = (trajets || []).map(enrich); return { train: all.filter(r => r.mode === 'train').length, plane: all.filter(r => r.mode === 'plane').length, bus: all.filter(r => r.mode === 'bus').length, ship: all.filter(r => r.mode === 'ship').length, morning: all.filter(r => timeSlot(r.depart) === 'morning').length, afternoon: all.filter(r => timeSlot(r.depart) === 'afternoon').length, evening: all.filter(r => timeSlot(r.depart) === 'evening').length, direct: all.filter(r => r.stops === 'direct').length, oneStop: all.filter(r => r.stops === '1 escale').length, twoStops: all.filter(r => r.stops === '2 escales').length, priceMin: all.length ? Math.min(...all.map(r => r.price)) : 0, priceMax: all.length ? Math.max(...all.map(r => r.price)) : 9999, }; }, [trajets]); return (
go('home')}>{t('Accueil')} / {t('Trajets disponibles')}
{t('Recherche')}
2{t('Trajet')}
3{t('Voyageurs')}
4{t('Confirmation')}

{currentSearch.depart} → {currentSearch.arrivee} · {currentSearch.date} {isRoundTrip(currentSearch.tripType) && currentSearch.dateRetour && ` ↔ ${currentSearch.dateRetour}`}

{tripTypeLabel(currentSearch.tripType)} {currentSearch.depart} {currentSearch.arrivee} {currentSearch.date} {isRoundTrip(currentSearch.tripType) && currentSearch.dateRetour && ( <> {t('retour')} {currentSearch.dateRetour} )} {paxLabel(normalizePax(currentSearch))} {editOpen && ( setEditOpen(false)} onApply={(v) => setCurrentSearch(v)} /> )}
{fallback && trajets && trajets.length > 0 && (
{t('Désolé — aucun trajet trouvé pour {depart} →', { depart: currentSearch.depart })} {currentSearch.arrivee}
{t('Cette destination n’est pas (encore) dans notre catalogue, ou aucun trajet n’est programmé.')}{' '} {t('Voici d’')}{t('autres destinations')}{t(' au départ de ')}{currentSearch.depart} —{' '} {t('ces trajets ne vont ')}{t('pas')}{t(' à ')}{currentSearch.arrivee}.
)} {!loading && (trajets && trajets.length > 0) && sorted.length === 0 && (
{trajets.length} {t('trajets disponibles mais cachés par vos filtres.')}
)}
{t('{n} trajets', { n: sorted.length })} {sorted.length > 0 && · {t('dès {prix} €', { prix: Math.min(...sorted.map(r => r.price)) })}}
{[ ['best', t('Meilleur')], ['cheap', t('Moins cher')], ['fast', t('Plus rapide')], ['early', t('Départ tôt')], ].map(([k, l]) => ( ))}
{loading && (
{t('Recherche en cours…')}
)} {!loading && sorted.length === 0 && (
{t('Aucun trajet trouvé. Essayez d’élargir vos critères.')}
)} {!loading && sorted.map((r) => ( { go('booking', { route, search: currentSearch }); }} /> ))}
); }; /* ────────────────────────────────────────────────────────── RÉSERVATION (booking detail + form) ──────────────────────────────────────────────────────── */ const BOOKING_PHOTO = 'https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=1400&q=85&auto=format'; const BookingPage = ({ go, ctx, auth, onRequestLogin }) => { const r = ctx && ctx.route; if (!r) { React.useEffect(() => go('home'), []); return null; } const Icon = MODE_ICONS[r.mode] || ITrain; const u = auth && auth.user; const sCtx = (ctx && ctx.search) || {}; const pax = normalizePax(sCtx); const tripType = sCtx.tripType || 'aller'; const dateRetour = sCtx.dateRetour; const classOpts = CLASS_OPTIONS[r.mode] || CLASS_OPTIONS.train; const bagOpts = BAG_OPTIONS[r.mode] || []; const [classId, setClassId] = React.useState(() => guessClassId(r.class, r.mode) || classOpts[0].id); const [bags, setBags] = React.useState(() => Object.fromEntries(bagOpts.map(b => [b.id, 0]))); const totals = computeBookingTotal({ basePrice: r.price, mode: r.mode, pax, classId, bags, tripType }); const [form, setForm] = React.useState({ nom: u ? u.nom : '', prenom: u ? u.prenom : '', dob: '', email: u ? u.email : '', tel: '', }); React.useEffect(() => { if (u) setForm((f) => ({ ...f, nom: u.nom, prenom: u.prenom, email: u.email })); }, [u && u.id]); const setField = (k) => (e) => { setError(null); setForm((f) => ({ ...f, [k]: e.target.value })); }; // tel est optionnel côté backend, on ne l'exige plus côté front const requiredOk = form.nom && form.prenom && form.dob && form.email; const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); // Photo de fond du hero : illustre la ville de DÉPART (Paris → Tour Eiffel, etc.). // L'image vient de l'API /images (Wikipedia puis Pixabay), avec repli sur la photo générique. const [heroPhoto, setHeroPhoto] = React.useState(BOOKING_PHOTO); React.useEffect(() => { const city = r && r.from ? r.from : null; if (!city) return; let alive = true; window.VA_API.cityImage(city) .then((res) => { if (alive && res && res.url) setHeroPhoto(res.url); }) .catch(() => { /* on garde la photo par défaut */ }); return () => { alive = false; }; }, [r && r.from]); const dobToISO = (s) => { if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; const m = s.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/); if (m) return `${m[3]}-${m[2].padStart(2,'0')}-${m[1].padStart(2,'0')}`; return null; }; const confirmAndPay = async () => { setError(null); if (!auth || !auth.isAuth) { onRequestLogin && onRequestLogin('signup'); return; } const missing = []; if (!form.nom) missing.push(t('Nom')); if (!form.prenom) missing.push(t('Prénom')); if (!form.dob) missing.push(t('Date de naissance')); if (!form.email) missing.push(t('Email')); if (missing.length) { setError(t('Merci de remplir : {champs}.', { champs: missing.join(', ') })); return; } const dob = dobToISO(form.dob); if (!dob) { setError(t("Date de naissance invalide (utiliser JJ/MM/AAAA).")); return; } if (!r._api || !r._api.id) { go('confirm', { route: r, form, totals, pax, classId, bags }); return; } setBusy(true); try { const billet = await window.VA_API.createBillet({ trajet_id: r._api.id, voyageur: { nom: form.nom, prenom: form.prenom, date_naissance: dob, email: form.email, telephone: form.tel || null, }, prix_paye: totals.total, classe: totals.cls.label, nb_places: pax.adultes + pax.enfants, }); go('confirm', { route: r, form, billet, ticketNum: billet.numero_billet, totals, pax, classId, bags }); } catch (e) { setError(t('Erreur de réservation : {message}', { message: e.message })); } finally { setBusy(false); } }; return (
go('home')}>{t('Accueil')} / go('results')}>{t('Résultats')} / {t('Réservation')}
{t('Recherche')}
{t('Trajet')}
3{t('Voyageurs')}
4{t('Confirmation')}
{t('Trajet')} · {r.mode === 'plane' ? t('Vol') : r.mode === 'bus' ? t('Bus longue distance') : r.mode === 'ship' ? t('Bateau') : t('Train')} {r.company}

{r.from.split(' ')[0]} → {r.to.split(' ')[0]}

{(r.dateLong || r.dateShort) && {r.dateLong || r.dateShort}}
{r.depart}
{r.dateShort &&
{r.dateShort}
}
{r.from}
{r.from.includes('CDG') ? t('Terminal 2F') : t('Hall 2 · Voie L')}
{r.dur}
0 ? 'var(--va-accent)' : undefined }}>{r.stops}
{r.escales && r.escales.length > 0 &&
{t('via')} {r.escales.join(' · ')}
}
{r.arrive}
{(r.dateArriveeShort || r.dateShort) &&
{r.dateArriveeShort || r.dateShort}
}
{r.to}
{r.to.includes('LYS') ? t('Terminal 1') : t('Voie 3')}
{t('Compagnie')} {r.company}
{t('Classe / tarif')} {r.class}
{t('Échange')} {t('Avant J–1')}
{t('Remboursement')} {t('Partiel')}
{/* Sélection de classe */}
{t('Choisissez votre classe')}
{t('Le tarif s’adapte au confort et aux services inclus.')}
{classOpts.map((c) => { const sel = classId === c.id; const adultPrice = Math.round(r.price * c.mult); return ( ); })}
{/* Bagages */}
{t('Bagages')}
{BAG_INCLUDED_LABEL}
{bagOpts.length === 0 ? (
{r.mode === 'train' ? t('En train : bagages illimités inclus dans la limite de ce que vous portez.') : r.mode === 'ship' ? t('À bord : bagages illimités inclus selon votre cabine.') : t('Aucune option de bagage supplémentaire pour ce mode.')}
) : (
{bagOpts.map((b) => (
{t(b.label)}
{t(b.sub)} · {b.price} € {t('/ pièce')}
setBags((bb) => ({ ...bb, [b.id]: Math.max(0, (bb[b.id] || 0) - 1) }))} disabled={(bags[b.id] || 0) <= 0}>− {bags[b.id] || 0} setBags((bb) => ({ ...bb, [b.id]: (bb[b.id] || 0) + 1 }))}>+
))}
)}
{t('Voyageur principal')}
{t('Ces informations figureront sur le billet. Aucune création de compte requise.')}
{t('Le billet et les notifications de trafic vous seront envoyés à cette adresse.')}
{t('Vos données restent privées.')} {t('Aucun compte requis, l’assistant vérifiera votre identité à chaque accès au billet.')}
{t('Récapitulatif')}
{r.company} · {r.depart} → {r.arrive}
{t('Type de trajet')} {tripTypeLabel(tripType)}{isRoundTrip(tripType) ? ' (×2)' : ''}
{isRoundTrip(tripType) && dateRetour && (
{t('Retour prévu')}{dateRetour}
)}
{t('Classe')}{totals.cls.label}
{pax.adultes > 0 && (
{t('Adulte')} × {pax.adultes} {totals.adultPrice * pax.adultes} €
)} {pax.enfants > 0 && (
{t('Enfant')} × {pax.enfants} {t('(−50 %)')} {totals.childPrice * pax.enfants} €
)} {pax.bebes > 0 && (
{t('Bébé')} × {pax.bebes} {r.mode === 'plane' ? t('(−90 %)') : t('(gratuit)')} {totals.babyPrice * pax.bebes} €
)} {bagOpts.map((b) => (bags[b.id] || 0) > 0 && (
{t(b.label)} × {bags[b.id]} {b.price * bags[b.id]} €
))}
{t('Assurance annulation')}6,90 €
{t('Frais de service')}0 €
{t('Total TTC')} · {paxLabel(pax)}{paxBreakdown(pax) ? ` (${paxBreakdown(pax)})` : ''} {totals.total} €
{error && (
{error}
)} {!auth || !auth.isAuth ? ( <>
{t('Un compte est nécessaire pour finaliser la réservation et retrouver vos billets.')}
) : ( <> {!requiredOk && (
{t('Date de naissance et téléphone non remplis par Google — complétez-les ci-dessus.')}
)} {requiredOk && (
{t('Paiement sécurisé · Cartes & SEPA · Sans engagement')}
)} )}
); }; /* ────────────────────────────────────────────────────────── CONFIRMATION ──────────────────────────────────────────────────────── */ const genTicketNum = () => { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; const nums = '23456789'; const pick = (set, n) => Array.from({ length: n }, () => set[Math.floor(Math.random() * set.length)]).join(''); return `TRV-2026-${pick(chars, 2)}${pick(nums, 2)}${pick(chars, 1)}${pick(nums, 1)}`; }; const ConfirmPage = ({ go, ctx }) => { const r = ctx && ctx.route; const f = (ctx && ctx.form) || {}; const totals = (ctx && ctx.totals) || (r ? { total: r.price, cls: { label: r.class || 'Standard' } } : { total: 0, cls: { label: 'Standard' } }); if (!r) { React.useEffect(() => go('home'), []); return null; } const [num] = React.useState(() => (ctx && ctx.ticketNum) || genTicketNum()); const Icon = MODE_ICONS[r.mode] || ITrain; return (

{t('Bon voyage, {prenom}.', { prenom: f.prenom })}
{t('Votre billet est')} {t('confirmé.')}

{t('Nous venons d’envoyer votre billet à')} {f.email}.{' '} {t('Vous pourrez le retrouver à tout moment depuis « Mes billets » ou en demandant à l’assistant.')}
{t('Numéro de billet')} {num}
{r.company}
{r.class}
{r.depart}
{r.dateShort &&
{r.dateShort}
}
{r.from}
{r.dur}
0 ? 'var(--va-accent)' : undefined }}>{r.stops}
{r.escales && r.escales.length > 0 &&
{t('via')} {r.escales.join(' · ')}
}
{r.arrive}
{(r.dateArriveeShort || r.dateShort) &&
{r.dateArriveeShort || r.dateShort}
}
{r.to}
{t('À présenter à l’embarquement')}
{t('Voyageur')} {f.prenom} {f.nom}
{t('Voiture / Siège')} {r.mode === 'plane' ? '24 A' : 'V.7 · 23 F'}
{t('Tarif')} {totals.cls.label}
{t('Total payé')} {totals.total} €
{t('E-mail envoyé · paiement débité (CB Visa •• 4218) · billet rétractable jusqu’à J–1')}
); }; /* ────────────────────────────────────────────────────────── MES BILLETS (number entry + verification flow) ──────────────────────────────────────────────────────── */ const MesBilletsPage = ({ go, ctx, auth }) => { const [step, setStep] = React.useState('enter'); // enter → verify → done const [num, setNum] = React.useState(''); const [id, setId] = React.useState({ nom: '', prenom: '', dob: '' }); const [verifying, setVerifying] = React.useState(false); const [verifyError, setVerifyError] = React.useState(''); const setField = (k) => (e) => setId((s) => ({ ...s, [k]: e.target.value })); const validNum = /^TRV-2026-[A-Z0-9]{6,}$/.test(num.trim().toUpperCase()); const handleVerify = async () => { setVerifying(true); setVerifyError(''); try { const res = await fetch(`${window.VA_CONFIG.API_BASE}/billets/access`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ numero_billet: num.trim().toUpperCase(), identity: { nom: id.nom, prenom: id.prenom, date_naissance: id.dob }, }), }); if (!res.ok) { setVerifyError(t('Numéro de billet ou identité incorrects.')); return; } const billet = await res.json(); const t = billet.trajet || {}; const dep = t.date_depart ? new Date(t.date_depart) : null; const arr = t.date_arrivee ? new Date(t.date_arrivee) : null; const hh = (d) => d ? `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}` : ''; const modeKey = { avion: 'plane', train: 'train', bateau: 'ship', bus: 'bus' }[t.type] || 'train'; const route = { id: t.id, mode: modeKey, company: t.compagnie || '', class: billet.tarif || 'Standard', depart: hh(dep), arrive: hh(arr), from: t.depart || '', to: t.arrivee || '', price: billet.prix_paye || 0, stops: t.stops || 'direct', }; go('confirm', { ticketNum: billet.numero_billet, form: { nom: billet.user?.nom || id.nom, prenom: billet.user?.prenom || id.prenom, email: billet.user?.email || '' }, route, totals: { total: billet.prix_paye, cls: { label: billet.tarif || 'Standard' } }, }); } catch { setVerifyError(t('Erreur de connexion au serveur.')); } finally { setVerifying(false); } }; // Si l'utilisateur est connecté : on charge directement ses billets const isAuth = !!(auth && auth.isAuth); const [myBillets, setMyBillets] = React.useState(null); const [loadingBillets, setLoadingBillets] = React.useState(false); React.useEffect(() => { if (!isAuth) { setMyBillets(null); return; } setLoadingBillets(true); window.VA_API.myBillets() .then((data) => setMyBillets(data || [])) .catch(() => setMyBillets([])) .finally(() => setLoadingBillets(false)); }, [isAuth, auth && auth.user && auth.user.id]); if (isAuth) { return (
go('home')}>{t('Accueil')} / {t('Mes billets')}

{t('Historique de mes billets')}

{t('Retrouvez ici tous les billets achetés avec ce compte ({email}).', { email: auth.user.email })}
{loadingBillets && (
{t('Chargement…')}
)} {!loadingBillets && myBillets && myBillets.length === 0 && (
{t('Aucun billet pour l’instant')}
{t('Vos prochaines réservations apparaîtront ici automatiquement.')}
)} {!loadingBillets && myBillets && myBillets.length > 0 && (
{myBillets.map((b) => { const t = b.trajet || {}; const dDep = t.date_depart ? new Date(t.date_depart) : null; const Icon = (t.type === 'avion' ? IPlane : t.type === 'bus' ? IBus : t.type === 'bateau' ? IShip : ITrain); const isFuture = dDep && dDep > new Date(); return (
{t.depart} → {t.arrivee} {b.statut === 'annule' ? window.t('Annulé') : isFuture ? window.t('À venir') : window.t('Terminé')}
{t.compagnie || '—'} · {t.classe || 'Standard'} {dDep && <> · {dDep.toLocaleDateString(window.VA_I18N.locale(), { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })} {window.t('à')} {dDep.toLocaleTimeString(window.VA_I18N.locale(), { hour: '2-digit', minute: '2-digit' })}}
{b.numero_billet}
{Math.round(b.prix_paye)} €
); })}
)}
); } return (
go('home')}>{t('Accueil')} / {t('Mes billets')}

{t('Accéder à un billet')}

{t('Aucune création de compte requise — votre numéro et une vérification d’identité suffisent.')}
{step === 'enter' ? : }
{step === 'enter' ? t('Votre numéro de billet') : step === 'verify' ? t('Vérifions votre identité') : t('Identité vérifiée')}
{step === 'enter' ? t('Le numéro figure dans l’e-mail de confirmation, format TRV-2026-XXXXXX.') : step === 'verify' ? t('Confirmez les informations du voyageur principal pour accéder au billet.') : t('Votre billet est en cours de chargement…')}
{step === 'enter' && (
setNum(e.target.value.toUpperCase())} placeholder="TRV-2026-XXXXXX" style={{ fontFamily: 'JetBrains Mono, ui-monospace, monospace', letterSpacing: '0.04em' }} />
{t('Vous avez perdu votre numéro ?')} go('assistant')}>{t('Demandez à l’assistant.')}
)} {step === 'verify' && (
{verifyError && (
{verifyError}
)}
)}
{t('Pas encore de billet ?')} go('home')}>{t('Faire une recherche')}
); }; // Version standalone de enrich() (cf. ResultsPage) pour réutilisation hors du composant // (ex. cartes de résultats dans le chatbot). Même mapping trajet API → route object. const enrichTrajet = (t) => { const modeKey = MODE_API_TO_KEY[t.type] || 'train'; const depart = new Date(t.date_depart); const arrive = new Date(t.date_arrivee); const hh = (d) => `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; const mins = Math.round((arrive - depart) / 60000); const dur = `${Math.floor(mins / 60)} h ${String(mins % 60).padStart(2,'0')}`; const fmtLong = (d) => isNaN(d) ? '' : d.toLocaleDateString(window.VA_I18N.locale(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); const fmtShort = (d) => isNaN(d) ? '' : d.toLocaleDateString(window.VA_I18N.locale(), { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); const tags = []; if (t.has_wifi) tags.push({ label: 'Wi-Fi', good: false }); if (t.has_prise) tags.push({ label: 'Prise', good: false }); if (t.type === 'train' || t.type === 'bus') tags.push({ label: 'Émission –93 %', good: true }); if (t.prix < 35) tags.push({ label: 'Petit prix', good: true }); if (t.retard_minutes > 0) tags.push({ label: 'Retard {min} min', vars: { min: t.retard_minutes }, good: false }); return { id: t.id, _api: t, company: t.compagnie, class: t.classe || 'Standard', depart: hh(depart), arrive: hh(arrive), dateLong: fmtLong(depart), dateShort: fmtShort(depart), dateArriveeShort: fmtShort(arrive), from: t.depart, to: t.arrivee, dur, stops: t.stops || 'direct', escales: t.escales || [], dureeEscaleMin: t.duree_escale_min || 0, price: Math.round(t.prix), mode: modeKey, tags, }; }; Object.assign(window, { HomePage, ResultsPage, BookingPage, ConfirmPage, MesBilletsPage, enrichTrajet, });