/* Voyage Assistant — shared UI pieces. Wordmark, Nav, ThemeToggle, SearchBar, ModeCards, DestinationGrid, FAB. All assume the icon globals from voyage-icons.jsx are loaded. */ const Wordmark = ({ size = 'md' }) => { const fz = size === 'lg' ? 19 : size === 'sm' ? 14 : 17; return (
Voyage· assistant
); }; const ThemeToggle = ({ active = 'light' }) => (
); const Nav = ({ bordered = false, theme = 'light' }) => ( ); const SearchBar = ({ values, compact = false }) => { const v = values || { from: { city: 'Paris', code: 'CDG · Gare de Lyon' }, to: { city: 'Marseille', code: 'MRS · Saint-Charles' }, date: { label: 'Sam. 13 juin', sub: '1 trajet' }, pax: { label: '2 voyageurs', sub: '1 adulte · 1 enfant' }, }; return (
{t('Départ')} {v.from.city} {!compact && {v.from.code}}
{t('Arrivée')} {v.to.city} {!compact && {v.to.code}}
{t('Date')} {v.date.label} {!compact && {v.date.sub}}
{t('Voyageurs')} {v.pax.label} {!compact && {v.pax.sub}}
); }; const MODES = [ { key: 'plane', label: 'Avion', sub: '450+ compagnies · 159 000 routes', meta: 'dès 49 €', Icon: IPlane }, { key: 'train', label: 'Train', sub: 'SNCF · Eurostar · DB · Trenitalia · Renfe', meta: 'dès 19 €', Icon: ITrain }, { key: 'ship', label: 'Bateau', sub: 'P&O · Brittany Ferries · Corsica Linea…', meta: 'dès 19 €', Icon: IShip }, { key: 'bus', label: 'Bus', sub: 'FlixBus · BlaBlaBus · liaisons EU', meta: 'dès 5 €', Icon: IBus }, ]; const ModeCards = ({ active = 'train' }) => (
{MODES.map(({ key, label, sub, meta, Icon }) => ( ))}
); const ModeTabs = ({ active = 'all', onSelect }) => (
{MODES.map(({ key, label, Icon }) => ( ))}
); const MODE_KEY_TO_API = { all: null, plane: 'avion', train: 'train', ship: 'bateau', bus: 'bus' }; const MODE_API_TO_KEY = { avion: 'plane', train: 'train', bateau: 'ship', bus: 'bus' }; const DestinationImg = ({ city, src }) => { const fallback = `https://picsum.photos/seed/${encodeURIComponent(city)}/600/400`; const [url, setUrl] = React.useState(src || fallback); React.useEffect(() => { setUrl(src || fallback); }, [src]); return (
setUrl(fallback)} />
); }; /* Carousel dynamique — charge les destinations depuis le backend */ const DestinationsCarousel = ({ perPage = 4, onPick, depart = 'Paris' }) => { const [destinations, setDestinations] = React.useState([]); const [images, setImages] = React.useState({}); const [page, setPage] = React.useState(0); const [loading, setLoading] = React.useState(true); // Charge les destinations réelles depuis le backend React.useEffect(() => { setLoading(true); fetch(`${window.VA_CONFIG.API_BASE}/trajets/destinations?depart=${encodeURIComponent(depart)}&limit=24`) .then(r => r.json()) .then(data => { setDestinations(Array.isArray(data) ? data : []); setLoading(false); }) .catch(() => { setDestinations([]); setLoading(false); }); }, [depart]); // Charge les images Pixabay pour les destinations visibles React.useEffect(() => { if (!destinations.length) return; const start = page * perPage; const visible = destinations.slice(start, start + perPage); visible.forEach(d => { if (images[d.ville]) return; fetch(`${window.VA_CONFIG.API_BASE}/images?q=${encodeURIComponent(d.ville + ' city travel')}&mode=ville`) .then(r => r.json()) .then(data => { if (data.url) setImages(prev => ({ ...prev, [d.ville]: data.url })); }) .catch(() => {}); }); }, [page, destinations]); if (loading) return (
{t('Chargement des destinations…')}
); if (!destinations.length) return null; const pages = Math.ceil(destinations.length / perPage); const visible = destinations.slice(page * perPage, page * perPage + perPage); return (
{visible.map((d, i) => ( ))}
{pages > 1 && (
{Array.from({ length: pages }).map((_, i) => ( ))}
)}
); }; const FAB = ({ label = t('Discuter avec l\u2019assistant') }) => ( ); /* Section: "Pourquoi Voyage" — value props row */ const ValueProps = () => (
{[ { Icon: ICheck, title: 'Un seul billet', sub: 'Multi-mode, multi-compagnies' }, { Icon: IShield, title: 'Garantie 0 stress', sub: 'Remboursement en 48 h' }, { Icon: ISparkles, title: 'Assistant 24 / 7', sub: 'Retards, modifications, réclas' }, { Icon: IGlobe, title: '3 000 destinations', sub: 'Europe, Afrique, Amériques, Asie' }, ].map(({ Icon, title, sub }, i) => (
{t(title)}
{t(sub)}
))}
); /* ────────────────────────────────────────────────────────── PRICING — pax / classes / bagages (style booking.com / Air France) ──────────────────────────────────────────────────────── */ const CLASS_OPTIONS = { plane: [ { id: 'eco', label: 'Économique', sub: 'Le tarif de base', mult: 1.0 }, { id: 'premium', label: 'Premium éco', sub: 'Plus d’espace, repas chaud', mult: 1.4 }, { id: 'business', label: 'Affaires', sub: 'Siège-lit, salon, prioritaire', mult: 2.2 }, { id: 'first', label: 'Première', sub: 'Suite privée, service dédié', mult: 3.0 }, ], train: [ { id: 'eco', label: '2ⁿᵈᵉ classe', sub: 'Confort standard', mult: 1.0 }, { id: 'premium', label: '1ʳᵉ classe', sub: 'Plus large, prise individuelle', mult: 1.5 }, ], bus: [ { id: 'eco', label: 'Standard', sub: 'Siège inclinable, Wi-Fi', mult: 1.0 }, { id: 'premium', label: 'Premium', sub: 'Plus d’espace, prise USB', mult: 1.3 }, ], ship: [ { id: 'eco', label: 'Pont', sub: 'Fauteuil sur le pont', mult: 1.0 }, { id: 'premium', label: 'Cabine intérieure', sub: '2 à 4 couchettes', mult: 1.6 }, { id: 'business', label: 'Cabine extérieure', sub: 'Hublot ou balcon privatif', mult: 2.2 }, ], }; const BAG_OPTIONS = { plane: [ { id: 'cabin_8', label: 'Bagage cabine 8 kg', sub: '55 × 40 × 20 cm', price: 15 }, { id: 'hold_23', label: 'Bagage en soute 23 kg', sub: '158 cm cumulés', price: 30 }, { id: 'hold_32', label: 'Bagage en soute 32 kg', sub: '158 cm cumulés', price: 55 }, ], bus: [{ id: 'big_bag', label: 'Grande valise en soute', sub: '80 × 50 × 30 cm', price: 10 }], train: [], ship: [], }; const BAG_INCLUDED_LABEL = 'Sac à dos cabine inclus · 40 × 20 × 25 cm (~7 kg)'; const _CLASS_LABEL_MAP = { // avion 'économique': 'eco', 'eco': 'eco', 'standard': 'eco', 'premium economy': 'premium', 'premium éco': 'premium', 'premium': 'premium', 'business': 'business', 'affaires': 'business', 'première': 'first', 'first': 'first', 'first class': 'first', // train '2nde · loisir': 'eco', '2nde · pro': 'eco', '2nde': 'eco', '1ère · pro': 'premium', '1ère': 'premium', // bateau 'pont': 'eco', 'cabine 2': 'premium', 'cabine 4': 'premium', 'cabine intérieure': 'premium', 'cabine premium': 'business', 'cabine extérieure': 'business', // bus }; const guessClassId = (classeLabel, mode) => { if (!classeLabel) return null; const key = classeLabel.toLowerCase().trim(); const guessed = _CLASS_LABEL_MAP[key] || null; const opts = CLASS_OPTIONS[mode] || CLASS_OPTIONS.train; if (guessed && opts.find(o => o.id === guessed)) return guessed; return null; }; /* Normalise les voyageurs : accepte ancien format (passagers: number) ou nouveau (pax: {adultes,enfants,bebes}) */ const normalizePax = (s) => { if (s && s.pax && typeof s.pax === 'object') { return { adultes: Math.max(1, s.pax.adultes || 0), enfants: Math.max(0, s.pax.enfants || 0), bebes: Math.max(0, s.pax.bebes || 0), }; } const n = (s && typeof s.passagers === 'number') ? s.passagers : 1; return { adultes: Math.max(1, n), enfants: 0, bebes: 0 }; }; const paxCount = (p) => (p.adultes || 0) + (p.enfants || 0) + (p.bebes || 0); const paxLabel = (p) => { const t = paxCount(p); return t > 1 ? window.t('{n} voyageurs', { n: t }) : window.t('{n} voyageur', { n: t }); }; const paxBreakdown = (p) => { const parts = []; if (p.adultes) parts.push(p.adultes > 1 ? window.t('{n} adultes', { n: p.adultes }) : window.t('{n} adulte', { n: p.adultes })); if (p.enfants) parts.push(p.enfants > 1 ? window.t('{n} enfants', { n: p.enfants }) : window.t('{n} enfant', { n: p.enfants })); if (p.bebes) parts.push(p.bebes > 1 ? window.t('{n} bébés', { n: p.bebes }) : window.t('{n} bébé', { n: p.bebes })); return parts.join(' · '); }; const computeBookingTotal = ({ basePrice, mode, pax, classId, bags, tripType }) => { const classes = CLASS_OPTIONS[mode] || CLASS_OPTIONS.train; const cls = classes.find(c => c.id === classId) || classes[0]; const tripMult = isRoundTrip(tripType) ? 2 : 1; const adultPrice = basePrice * cls.mult * tripMult; const childPrice = adultPrice * 0.5; const babyPrice = mode === 'plane' ? adultPrice * 0.1 : 0; const bagOpts = BAG_OPTIONS[mode] || []; // Bagages : compte aller + retour si aller-retour (chaque trajet a son lot) const bagsTotal = bagOpts.reduce((s, b) => s + (((bags && bags[b.id]) || 0) * b.price * tripMult), 0); const paxTotal = adultPrice * pax.adultes + childPrice * pax.enfants + babyPrice * pax.bebes; const insurance = 6.9; return { cls, tripMult, adultPrice: Math.round(adultPrice), childPrice: Math.round(childPrice), babyPrice: Math.round(babyPrice), paxTotal: Math.round(paxTotal), bagsTotal: Math.round(bagsTotal), insurance, total: Math.round(paxTotal + bagsTotal + insurance), }; }; /* TripTypeTabs — 3 pills : Aller simple / Retour seul / Aller-retour */ const TRIP_TYPES = [ { id: 'aller', label: 'Aller simple' }, { id: 'retour', label: 'Retour seul' }, { id: 'aller_retour', label: 'Aller-retour' }, ]; const TripTypeTabs = ({ value, onChange }) => (
{TRIP_TYPES.map((t) => { const sel = (value || 'aller') === t.id; return ( ); })}
); const tripTypeLabel = (t) => window.t((TRIP_TYPES.find(x => x.id === t) || TRIP_TYPES[0]).label); const isRoundTrip = (t) => t === 'aller_retour'; /* PaxStepper — popover avec +/- pour adultes / enfants / bébés */ const StepBtn = ({ onClick, disabled, children }) => ( ); const PaxStepper = ({ value, onChange, onValidate }) => { const pax = normalizePax({ pax: value }); const set = (k, v) => onChange({ ...pax, [k]: Math.max(0, v) }); const seats = (pax.adultes || 0) + (pax.enfants || 0); const Row = ({ label, sub, k, min = 0, capByseats = false }) => (
{t(label)}
{t(sub)}
set(k, pax[k] - 1)} disabled={pax[k] <= min}>− {pax[k]} set(k, pax[k] + 1)} disabled={capByseats ? false : seats >= 9}>+
); return (
{onValidate && ( )}
); }; Object.assign(window, { Wordmark, ThemeToggle, Nav, SearchBar, MODES, ModeCards, ModeTabs, DestinationGrid, DestinationsCarousel, FAB, ValueProps, MODE_KEY_TO_API, MODE_API_TO_KEY, CLASS_OPTIONS, BAG_OPTIONS, BAG_INCLUDED_LABEL, guessClassId, normalizePax, paxCount, paxLabel, paxBreakdown, computeBookingTotal, PaxStepper, StepBtn, TRIP_TYPES, TripTypeTabs, tripTypeLabel, isRoundTrip, });