/* 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) => (
))}
);
/* ──────────────────────────────────────────────────────────
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 }) => (
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,
});